diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..456db162
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,12 @@
+version: 2
+updates:
+ - package-ecosystem: 'gomod'
+ directory: '/'
+ schedule:
+ interval: 'daily'
+ open-pull-requests-limit: 20
+ rebase-strategy: auto
+ groups:
+ all:
+ name: 'All dependencies'
+ update_types: [all]
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 00000000..0286e77d
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,69 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "Build"
+
+on: [push]
+
+jobs:
+ build:
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Check out source code
+ uses: actions/checkout@v5
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: '1.25'
+ cache: true
+
+ - name: Fetch tags
+ run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
+
+ - name: Set tag
+ id: vars
+ run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
+
+ - name: Build
+ run: CGO_ENABLED=0 go build -ldflags "-X github.com/qovery/qovery-cli/utils.Version=${{ steps.vars.outputs.tag }}" .
+
+ test:
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Check out source code
+ uses: actions/checkout@v5
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: '1.25'
+ cache: true
+
+ - name: Test
+ run: go test -tags testing ./...
+
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out source code
+ uses: actions/checkout@v5
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: '1.25'
+ cache: true
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v8.0.0
+ with:
+ version: latest
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 00000000..7d1c6669
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,70 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [ main ]
+ schedule:
+ - cron: '42 10 * * 5'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'go' ]
+ # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+ # Learn more about CodeQL language support at https://git.io/codeql-language-support
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+ # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ # âšī¸ Command-line programs to run using the OS shell.
+ # đ https://git.io/JvXDl
+
+ # âī¸ If the Autobuild fails above, remove it and uncomment the following three lines
+ # and modify them (or add more) to build your code if your project
+ # uses a compiled language
+
+ #- run: |
+ # make bootstrap
+ # make release
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
deleted file mode 100644
index 18c97d4e..00000000
--- a/.github/workflows/pull_request.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-on: pull_request
-name: Pull Request
-jobs:
- tests:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@v2
- with:
- fetch-depth: 0
- -
- name: Fetch tags
- run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- -
- name: Set up Go
- uses: actions/setup-go@master
- with:
- go-version: 1.17.x
- -
- name: golangci-lint
- uses: golangci/golangci-lint-action@v2
- with:
- version: latest
- args: --timeout 5m
- -
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v1
- with:
- version: latest
- args: release --rm-dist --skip-publish --skip-validate
- env:
- GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml
deleted file mode 100644
index 0d8d1b94..00000000
--- a/.github/workflows/push.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-on: push
-name: Push
-jobs:
- tests:
- runs-on: ubuntu-latest
- steps:
- -
- name: Checkout
- uses: actions/checkout@v2
- with:
- fetch-depth: 0
- -
- name: Fetch tags
- run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- -
- name: Set up Go
- uses: actions/setup-go@master
- with:
- go-version: 1.16.x
- -
- name: golangci-lint
- uses: golangci/golangci-lint-action@v2
- with:
- version: latest
- args: --timeout 5m
- -
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v1
- with:
- version: latest
- args: release --rm-dist --skip-publish --skip-validate
- env:
- GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3afa5148..64f433bb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,56 +1,64 @@
-name: goreleaser
+name: Release
on:
- create:
+ push:
tags:
+ - 'v*'
jobs:
- qovery:
- runs-on: ubuntu-latest
+ release-and-packages:
+ runs-on: ubuntu-24.04
steps:
- -
- name: Checkout
- uses: actions/checkout@v2
+ - name: Checkout
+ uses: actions/checkout@v5
with:
fetch-depth: 0
- -
- name: Fetch tags
+
+ # tag/version check
+ - name: Fetch tags
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- - name: Ensure tag match the current version
- run: |
- if [ "v$(grep '// ci-version-check' pkg/version.go | sed -r 's/.+return\s"(.+)".+/\1/')" != "$(git tag | sort --version-sort | tail -1)" ] ; then
- echo "Tag version do not match application version"
- exit 1
- fi
- -
- name: Set up Go
- uses: actions/setup-go@master
- with:
- go-version: 1.17.x
- -
- name: golangci-lint
- uses: golangci/golangci-lint-action@v2
+ - name: Set tag
+ id: vars
+ run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
+
+ # build + lint
+ - name: Set up Go
+ uses: actions/setup-go@v6
with:
- version: latest
- args: --timeout 5m
- -
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v1
+ go-version: '1.25'
+ cache: true
+ # release new version on GitHub + Mac
+ - name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v6
with:
version: latest
- args: release --rm-dist
+ args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
- -
- name: Prepare AUR package
+ # upload release artifacts to Cloudflare R2 (S3 compatible)
+ - name: Upload release artifacts to Cloudflare R2
+ if: github.ref_type == 'tag'
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
+ AWS_REGION: auto
run: |
- version=$(awk -F'"' '/ci-version-check/{print $2}' pkg/version.go)
+ # List all files in dist directory
+ echo "Uploading release artifacts to Cloudflare R2... (S3 compatible)"
+ ls -la dist/
+
+ # Upload all artifacts to Cloudflare R2
+ aws s3 cp dist/ s3://${{ secrets.CLOUDFLARE_R2_BUCKET }}/releases/latest/ --recursive --endpoint-url ${{ secrets.CLOUDFLARE_R2_ENDPOINT_URL }}
+ # archlinux
+ - name: Prepare AUR package
+ run: |
+ version="${GITHUB_REF_NAME:-}"
+ version="${version#v}"
md5version=$(curl -sL https://github.com/Qovery/qovery-cli/archive/v${version}.tar.gz --output - | md5sum | awk '{ print $1 }')
sed -i "s/pkgver=tbd/pkgver=$version/" PKGBUILD
echo "md5sums=('${md5version}')" >> PKGBUILD
- -
- name: Publish AUR package
- uses: KSXGitHub/github-actions-deploy-aur@v2.2.4
+ - name: Publish AUR package
+ uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
with:
pkgname: qovery-cli
pkgbuild: ./PKGBUILD
@@ -58,5 +66,58 @@ jobs:
commit_email: ${{ secrets.AUR_EMAIL }}
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: Update AUR package
- ssh_keyscan_types: rsa,dsa,ecdsa,ed25519
- force_push: 'true'
+ ssh_keyscan_types: rsa,ecdsa,ed25519
+ force_push: "true"
+ # GitHub action usage
+ container:
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ # tag/version check
+ - name: Fetch tags
+ run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
+ - name: Set tag
+ id: vars
+ run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
+
+ # docker
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: us-east-1
+ - name: Login to Amazon ECR
+ id: login-ecr
+ uses: aws-actions/amazon-ecr-login@v2
+ with:
+ registry-type: public
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Build, tag, and push images
+ env:
+ ECR_REGISTRY: public.ecr.aws/r3m4q3r9
+ ECR_REPOSITORY: qovery-cli
+ GHCR_REGISTRY: ghcr.io
+ GHCR_REPOSITORY: qovery/qovery-cli
+ IMAGE_TAG: ${{ steps.vars.outputs.tag }}
+ run: |
+ docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . --build-arg APP_VERSION=$IMAGE_TAG --label org.opencontainers.image.source=https://github.com/Qovery/qovery-cli
+ docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
+ docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $GHCR_REGISTRY/$GHCR_REPOSITORY:$IMAGE_TAG
+ docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $GHCR_REGISTRY/$GHCR_REPOSITORY:latest
+ docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
+ docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
+ docker push $GHCR_REGISTRY/$GHCR_REPOSITORY:$IMAGE_TAG
+ docker push $GHCR_REGISTRY/$GHCR_REPOSITORY:latest
diff --git a/.github/workflows/release_latest.yml b/.github/workflows/release_latest.yml
new file mode 100644
index 00000000..95648b21
--- /dev/null
+++ b/.github/workflows/release_latest.yml
@@ -0,0 +1,29 @@
+name: Release Latest
+on:
+ push:
+ branches: [main]
+jobs:
+ tests:
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Fetch tags
+ run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: '1.25'
+ cache: true
+
+ - name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v6
+ with:
+ version: latest
+ args: release --clean --skip=publish,validate
+ env:
+ GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 70cd1de1..9915decf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -90,3 +90,16 @@ dist/
*.iml
bin
qovery
+qovery-cli
+
+# Nix
+result
+
+# Direnv
+.envrc
+.direnv/
+.vscode/
+
+# Test coverage
+coverage.out
+coverage.html
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 00000000..8eb1e22f
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,5 @@
+version: "2"
+
+run:
+ build-tags: ["testing"]
+ timeout: 5m
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 214f65c3..386dfc1d 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -1,6 +1,10 @@
+version: 2
+
builds:
- main: main.go
binary: qovery
+ ldflags:
+ - -s -w -X github.com/qovery/qovery-cli/utils.Version={{ .Version }}
goos:
- darwin
- linux
@@ -8,45 +12,50 @@ builds:
goarch:
- amd64
- arm64
+
archives:
- format_overrides:
- goos: windows
format: zip
+
checksum:
name_template: 'checksums.txt'
+
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
+
brews:
- name: qovery-cli
- goarm: 6
- tap:
+ goarm: "6"
+ repository:
owner: qovery
name: homebrew-qovery-cli
url_template: "https://github.com/Qovery/qovery-cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
commit_author:
name: qovery
email: contact@qovery.com
- folder: Formula
+ directory: Formula
homepage: "https://docs.qovery.com"
description: "Deploy modern application in seconds"
skip_upload: false
install: |
bin.install "qovery"
-scoop:
- url_template: "https://github.com/Qovery/qovery-cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
- bucket:
- owner: qovery
- name: scoop-qovery-cli
- commit_author:
- name: qovery
- email: contact@qovery.com
- homepage: "https://docs.qovery.com"
- description: "Deploy modern application in seconds"
- license: GPL3
- persist:
- - "data"
- - "config.toml"
+
+scoops:
+ - url_template: "https://github.com/Qovery/qovery-cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
+ repository:
+ owner: qovery
+ name: scoop-qovery-cli
+ commit_author:
+ name: qovery
+ email: contact@qovery.com
+ homepage: "https://docs.qovery.com"
+ description: "Deploy modern application in seconds"
+ license: GPL3
+ persist:
+ - "data"
+ - "config.toml"
diff --git a/.mise.toml b/.mise.toml
new file mode 100644
index 00000000..b6a0be08
--- /dev/null
+++ b/.mise.toml
@@ -0,0 +1,59 @@
+# mise configuration for qovery-cli
+# Install mise: https://mise.jdx.dev/getting-started.html
+# Usage: mise install
+
+[tools]
+# Go version - latest stable release
+go = "1.25.1"
+
+# Development tools
+"golangci-lint" = "2.5.0" # Latest stable version
+
+[env]
+# Environment variables can be set here
+_.path = ["./bin", "$PATH"]
+
+[tasks.build]
+description = "Build the CLI"
+run = "go build -ldflags \"-X github.com/qovery/qovery-cli/utils.Version=$(git describe --tags --always)\" -o qovery ."
+
+[tasks.test]
+description = "Run tests"
+run = "go test -tags=testing ./..."
+
+[tasks.test-verbose]
+description = "Run tests with verbose output"
+run = "go test -v -tags=testing ./..."
+
+[tasks.test-coverage]
+description = "Run tests with coverage"
+run = [
+ "go test -tags=testing -coverprofile=coverage.out ./...",
+ "go tool cover -html=coverage.out -o coverage.html",
+ "echo 'Coverage report generated: coverage.html'"
+]
+
+[tasks.lint]
+description = "Run linter"
+run = "golangci-lint run ./..."
+
+[tasks.clean]
+description = "Clean build artifacts"
+run = "rm -rf dist/ coverage.out coverage.html qovery"
+
+[tasks.install]
+description = "Install CLI locally"
+depends = ["build"]
+run = """
+if [ -z "$GOPATH" ]; then
+ echo "Error: GOPATH is not set"
+ exit 1
+fi
+cp qovery $GOPATH/bin/
+echo "Installed to $GOPATH/bin/qovery"
+"""
+
+[tasks.ci-local]
+description = "Run CI checks locally"
+depends = ["lint", "test", "build"]
+run = "echo 'â
All CI checks passed!'"
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..5190da86
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,57 @@
+FROM public.ecr.aws/r3m4q3r9/pub-mirror-go:1.25.1 as builder
+
+ARG APP_VERSION=unknown
+
+# Set the working directory within the container
+WORKDIR /app
+
+# Copy go.mod and go.sum files to the container's working directory
+COPY go.mod go.sum ./
+
+# Download dependencies
+# Retried: proxy.golang.org intermittently resets HTTP/2 streams mid-download.
+# Only transport failures are worth retrying. If the proxy or VCS returned a
+# definitive answer, or the error is in local files, another attempt cannot
+# change it, so fail fast instead of sleeping through the backoff.
+RUN --mount=type=cache,target=/go/pkg/mod \
+ for attempt in 1 2 3 4 5; do \
+ if go mod download >/tmp/godl.log 2>&1; then exit 0; fi; \
+ cat /tmp/godl.log; \
+ if grep -qE 'SECURITY ERROR|checksum mismatch|missing go.sum entry|errors parsing go.mod|invalid version|unknown revision|malformed module path|module lookup disabled' /tmp/godl.log; then \
+ echo "go mod download failed with a non-transient error; not retrying"; \
+ exit 1; \
+ fi; \
+ echo "go mod download failed (attempt ${attempt}/5)"; \
+ [ "${attempt}" = 5 ] && break; \
+ sleep $((attempt * 5)); \
+ done; \
+ exit 1
+
+# Copy the source code to the container's working directory
+COPY . .
+
+# Build the Go application
+RUN --mount=type=cache,target=/go/pkg/mod \
+ --mount=type=cache,target=/root/.cache/go-build \
+ go build -o qovery -ldflags "-X github.com/qovery/qovery-cli/utils.Version=$APP_VERSION"
+
+FROM public.ecr.aws/r3m4q3r9/pub-mirror-debian:bookworm-slim as runner
+
+RUN apt-get update && \
+ apt-get -y upgrade && \
+ apt-get install -y --no-install-recommends ca-certificates && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists
+
+WORKDIR /app
+
+# make the exec.sh file executable
+COPY docker/ docker
+RUN chmod +x ./docker/exec.sh
+
+COPY --from=builder /app/qovery /app/qovery
+
+# Add the /app directory to the PATH environment variable
+ENV PATH="/app:${PATH}"
+
+ENTRYPOINT ["sh", "./docker/exec.sh"]
diff --git a/PKGBUILD b/PKGBUILD
index 4b4ad212..246ce790 100644
--- a/PKGBUILD
+++ b/PKGBUILD
@@ -15,7 +15,8 @@ build() {
export CGO_CPPFLAGS="${CPPFLAGS}"
export CGO_CXXFLAGS="${CXXFLAGS}"
export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw"
- go build -o $pkgname main.go
+ export CGO_ENABLED=0
+ go build -ldflags "-X github.com/qovery/qovery-cli/utils.Version=$pkgver" -o $pkgname main.go
}
package() {
diff --git a/README.md b/README.md
index aa0a1e8e..44d1ee3f 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,89 @@
-
+
[Qovery](https://www.qovery.com/) helps tech companies to accelerate and scale application development cycle with zero infrastructure management investment.
This repository is the code source of the Qovery CLI.
-See our complete documentation [here](https://docs.qovery.com) to get started with Qovery.
+See the [Qovery documentation](https://docs.qovery.com) to get started with Qovery.
+
+See the [Qovery CLI documentation](https://www.qovery.com/docs/cli/overview) to get started with the CLI and explore its commands.
+
+## Installation
+
+Choose the installation method for your platform.
+
+### Linux
+
+Install the latest version on any Linux distribution:
+
+```sh
+curl -s https://get.qovery.com | bash
+```
+
+For security-sensitive environments, install from a [pinned GitHub release](https://github.com/Qovery/qovery-cli/releases) and verify the downloaded archive against that release's `checksums.txt`. The convenience script does not perform an integrity check.
+
+### macOS
+
+Install with Homebrew:
+
+```sh
+brew tap Qovery/qovery-cli
+brew install qovery-cli
+```
+
+Alternatively, use the installer script:
+
+```sh
+curl -s https://get.qovery.com | bash
+```
+
+For a pinned, checksum-verified installation, use a [GitHub release](https://github.com/Qovery/qovery-cli/releases).
+
+### Windows
+
+Install with [Scoop](https://scoop.sh/):
+
+```powershell
+scoop bucket add qovery https://github.com/Qovery/scoop-qovery-cli
+scoop install qovery-cli
+```
+
+You can also download a release archive from [GitHub Releases](https://github.com/Qovery/qovery-cli/releases) and add the extracted `qovery` executable to your `PATH`.
+
+### Arch Linux
+
+The CLI is available through the AUR:
+
+```sh
+yay qovery-cli
+```
+
+### Docker
+
+Run the CLI without installing it locally:
+
+```sh
+docker run ghcr.io/qovery/qovery-cli:latest help
+```
+
+Replace `latest` with a specific version when you need reproducible builds.
+
+### Verify the installation
+
+```sh
+qovery version
+```
+
+## Authentication
+
+You can use `qovery auth` to authenticate with the CLI or use `Q_CLI_ACCESS_TOKEN` (or `QOVERY_CLI_ACCESS_TOKEN`) environment variable to set your API token.
+
+# Update deps
+
+```sh
+go get -u github.com/qovery/qovery-client-go
+go build
+go fmt .
+```
diff --git a/cmd/admin.go b/cmd/admin.go
index 4c4e6a11..b7aff438 100644
--- a/cmd/admin.go
+++ b/cmd/admin.go
@@ -5,13 +5,25 @@ import (
)
var (
- clusterId string
- lockReason string
- orgaErr error
- dryRun bool
- version string
- versionErr error
- adminCmd = &cobra.Command{Use: "admin", Hidden: true}
+ jwtKid string
+ clusterId string
+ clusterKubeconfig string
+ organizationId string
+ projectId string
+ lockReason string
+ orgaErr error
+ dryRun bool
+ noConfirm bool
+ version string
+ versionErr error
+ ageInDay int
+ execId string
+ directory string
+ rootDns string
+ additionalClaims string
+ description string
+ lockTtlInDays int32
+ adminCmd = &cobra.Command{Use: "admin", Hidden: true}
)
func init() {
diff --git a/cmd/admin_cluster.go b/cmd/admin_cluster.go
new file mode 100644
index 00000000..7c8575ed
--- /dev/null
+++ b/cmd/admin_cluster.go
@@ -0,0 +1,32 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminClusterCmd = &cobra.Command{
+ Use: "cluster",
+ Short: "Manage clusters",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+ }
+ // TODO (mzo) add parameter to random deploy clusters
+ // TODO (mzo) be able to handle upgrades of STOPPED clusters, to automatically upgrade & stop them
+ // TODO (mzo) handle pending clusters queue, when clusters couldn't be deployed because not in a final state
+ // TODO (mzo) handle progression in a file to let resume from the last deployment launched in case of interruption
+)
+
+func init() {
+ adminCmd.AddCommand(adminClusterCmd)
+}
diff --git a/cmd/admin_cluster_deploy.go b/cmd/admin_cluster_deploy.go
new file mode 100644
index 00000000..5d63b193
--- /dev/null
+++ b/cmd/admin_cluster_deploy.go
@@ -0,0 +1,138 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminClusterDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy or upgrade clusters",
+ Long: `This command has 2 main purposes:
+* deploy / redeploy clusters: mainly used to update Qovery components (agent / charts / etc.)
+* upgrade clusters: used to upgrade to next kube version supported
+
+> Filters
+---------
+Apply filters using the "--filters" option: filters can be applied to one or more values separated by comma interpreted as logical OR.
+The fields usable as filters are the following ones:
+* OrganizationId
+* OrganizationName
+* OrganizationPlan
+* ClusterId
+* ClusterName
+* ClusterType
+* ClusterK8sVersion
+* Mode
+* IsProduction
+* CurrentStatus
+* HasPendingUpdate
+
+Not implemented yet: filtering from last deployed date or created date
+
+> Parallel Run number
+---------------------
+The option "--parallel-run" (-n) specifies the number of parallel cluster deployments to be launched (default = 5)
+The deployments are launched locally on the workstation, not on a server-side thread.
+* if the value is > 20 the cluster autoscaler should be updated manually (the command displays a message and requires an approval to be launched)
+* the maximum value cannot exceed 100
+
+> Execution Mode
+----------------
+The option "--execution-mode" specifies which mode is applied on execution:
+* "--execution-mode=batch" (default): deployments are triggered sequentially by batch of N parallel-runs. The next batch of deployments will be launched only after all previous batch deployments
+* "--execution-mode=on-the-fly": deployments are triggered as soon as there is a slot available in a thread pool of N parallel-runs
+
+> New K8S Version
+-----------------
+The option "--new-k8s-version" specifies the next kubernetes version to be applied.
+When using this option, the recommendation is to have a low parallel runs number and an execution mode on batch, to be able to monitor clusters peacefully.
+
+> Refresh Delay
+---------------
+The option "--refresh-delay" specifies the amount of time to wait before fetching new cluster statuses during the deployments.
+
+> Disable Dry Run
+-----------------
+This option "--disable-dry-run" is mandatory to trigger the deployments
+
+> Examples
+----------
+* Redeploy only 2 clusters and ensure they are non production
+qovery admin cluster deploy -f ClusterName="ClusterA,ClusterB" -f IsProduction=false
+
+* Upgrade cluster having id "80981324-b6u7-400b-97fc-e2173d46a00e" to kube version "1.28" with refreshing statuses locally every "100" seconds
+qovery admin cluster deploy -f ClusterId=80981324-b6u7-400b-97fc-e2173d46a00e --new-k8s-version=1.28 --refresh-delay=100 --disable-dry-run
+
+* Upgrade by batch of "8" parallel runs every "1.27" Kubernetes "Production" clusters on "AWS" to kubernetes version "1.28" with refreshing statuses locally every "100" seconds
+qovery admin cluster deploy -f IsProduction=true --parallel-run=8 --refresh-delay=100 -f ClusterK8sVersion=1.27 --new-k8s-version=1.28 -f ClusterType=AWS --disable-dry-run
+
+* Redeploy by batch of "9" parallel runs every "1.27" Kubernetes clusters on "GCP" that have the last deployment status to "DEPLOYMENT_ERROR"
+qovery admin cluster deploy -f ClusterType=GCP --parallel-run=9 -f ClusterK8sVersion=1.27 -f CurrentStatus=DEPLOYMENT_ERROR --disable-dry-run
+`,
+ Run: func(cmd *cobra.Command, args []string) {
+ deployClusters()
+ },
+ }
+ refreshDelay int
+ filters map[string]string
+ executionMode string
+ newK8sVersion string
+ parallelRuns int
+)
+
+func init() {
+ adminClusterDeployCmd.Flags().BoolVarP(&noConfirm, "no-confirm", "c", false, "Do not prompt for confirmation")
+ adminClusterDeployCmd.Flags().BoolVarP(&dryRun, "disable-dry-run", "y", false, "Disable dry run mode")
+ adminClusterDeployCmd.Flags().IntVarP(¶llelRuns, "parallel-run", "n", 5, "Number of clusters to update in parallel - must be set between 1 and 20")
+ adminClusterDeployCmd.Flags().IntVarP(&refreshDelay, "refresh-delay", "r", 30, "Time in seconds to wait before checking clusters status during deployment - must be between [5-120]")
+ adminClusterDeployCmd.Flags().StringToStringVarP(&filters, "filters", "f", make(map[string]string), "Value(s) to filter the property selected separated by comma when multiple values are defined")
+ adminClusterDeployCmd.Flags().StringVarP(&executionMode, "execution-mode", "e", "batch", "Batch execution mode - 'batch' will wait for the N deployments to be finished and ask validation to continue - 'on-the-fly' will deploy continuously as soon as a slot is available")
+ adminClusterDeployCmd.Flags().StringVarP(&newK8sVersion, "new-k8s-version", "k", "", "K8S version when upgrading clusters")
+ adminClusterCmd.AddCommand(adminClusterDeployCmd)
+
+}
+
+func deployClusters() {
+ utils.GetAdminUrl()
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ // if no filter is set, enforce to select only RUNNING clusters to avoid mistakes (e.g deploying a stopped cluster)
+ _, containsKey := filters["CurrentStatus"]
+ if !containsKey {
+ filters["CurrentStatus"] = "DEPLOYED"
+ }
+
+ listService, err := pkg.NewAdminClusterListServiceImpl(filters)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ deployService, err := pkg.NewAdminClusterBatchDeployServiceImpl(client.ClustersAPI, dryRun, parallelRuns, refreshDelay, executionMode, newK8sVersion, noConfirm)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = pkg.DeployClustersByBatch(listService, deployService, noConfirm)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
diff --git a/cmd/admin_cluster_list.go b/cmd/admin_cluster_list.go
new file mode 100644
index 00000000..2b176d1a
--- /dev/null
+++ b/cmd/admin_cluster_list.go
@@ -0,0 +1,71 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminClusterListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List clusters by applying any filter",
+ Long: `This command is used to list clusters information using filters.
+The endpoint fetched by the CLI return all clusters except the locked ones.
+
+> Filters
+---------
+Apply filters using the "--filters" option: filters can be applied to one or more values separated by comma interpreted as logical OR.
+The fields usable as filters are the following ones:
+* OrganizationId
+* OrganizationName
+* OrganizationPlan
+* ClusterId
+* ClusterName
+* ClusterType
+* ClusterK8sVersion
+* Mode
+* IsProduction
+* CurrentStatus
+* HasPendingUpdate
+
+Not implemented yet: filtering from last deployed date or created date
+
+> Examples
+----------
+* Display every production cluster on cloud providers AWS and GCP:
+qovery admin cluster list -f IsProduction=true -f ClusterType=AWS,GCP
+
+* Display every deployed cluster on organization "FooBar":
+qovery admin cluster list -f OrganizationName=FooBar -f CurrentStatus=DEPLOYED
+`,
+ Run: func(cmd *cobra.Command, args []string) {
+ listClusters()
+ },
+ }
+)
+
+func init() {
+ adminClusterListCmd.Flags().StringToStringVarP(&filters, "filters", "f", make(map[string]string), "Value(s) to filter the property selected separated by comma when multiple values are defined")
+ adminClusterCmd.AddCommand(adminClusterListCmd)
+}
+
+func listClusters() {
+ utils.GetAdminUrl()
+
+ listService, err := pkg.NewAdminClusterListServiceImpl(filters)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ err = pkg.ListAllClusters(listService)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
diff --git a/cmd/admin_cluster_status.go b/cmd/admin_cluster_status.go
new file mode 100644
index 00000000..e0536c4c
--- /dev/null
+++ b/cmd/admin_cluster_status.go
@@ -0,0 +1,429 @@
+package cmd
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/appscode/go-querystring/query"
+ "github.com/gorilla/websocket"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+ "net/http"
+ "net/url"
+ "os"
+ "regexp"
+ "sort"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminClusterStatusCmd = &cobra.Command{
+ Use: "status",
+ Short: "Get cluster status",
+ Run: func(cmd *cobra.Command, args []string) {
+ printClusterStatus()
+ },
+ }
+)
+
+func init() {
+ adminClusterStatusCmd.Flags().StringVar(&organizationId, "organization-id", "", "The cluster's organization ")
+ adminClusterStatusCmd.Flags().StringVar(&clusterId, "cluster-id", "", "The cluster id to target")
+ adminClusterCmd.AddCommand(adminClusterStatusCmd)
+}
+
+func printClusterStatus() {
+ status, err := readClusterStatus(ClusterStatusRequest{
+ ClusterID: utils.Id(clusterId),
+ OrganizationID: utils.Id(organizationId),
+ })
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ renderClusterStatus(status)
+}
+
+func readClusterStatus(req ClusterStatusRequest) (*ClusterStatusDto, error) {
+ command, err := query.Values(req)
+ if err != nil {
+ return nil, err
+ }
+ websocketUrl := utils.WebsocketUrl()
+
+ wsURL, err := url.Parse(fmt.Sprintf("%s/cluster/status", websocketUrl))
+ if err != nil {
+ return nil, err
+ }
+
+ pattern := regexp.MustCompile("%5B([0-9]+)%5D=")
+ wsURL.RawQuery = pattern.ReplaceAllString(command.Encode(), "[${1}]=")
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ headers := http.Header{"Authorization": {utils.GetAuthorizationHeaderValue(tokenType, token)}}
+ wsConn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), headers)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ _ = wsConn.Close()
+ }()
+
+ msgType, payload, err := wsConn.ReadMessage()
+ if err != nil {
+ return nil, err
+ }
+
+ switch msgType {
+ case websocket.TextMessage:
+ var data ClusterStatusDto
+ err = json.Unmarshal(payload, &data)
+ if err != nil {
+ return nil, err
+ }
+ return &data, nil
+ default:
+ return nil, errors.New("received invalid message while fetching cluster status: " + string(rune(msgType)) + " " + string(payload))
+ }
+}
+
+func renderClusterStatus(clusterStatus *ClusterStatusDto) {
+ // Write the header
+ fmt.Printf("%-71s %-20s %-20s %-20s %-20s %-20s\n",
+ "",
+ pterm.Bold.Sprintf("%s", "RAM Alloc"),
+ pterm.Bold.Sprintf("%s", "RAM Usage"),
+ pterm.Bold.Sprintf("%s", "CPU Alloc"),
+ pterm.Bold.Sprintf("%s", "CPU Usage"),
+ pterm.Bold.Sprintf("%s", "Disk Usage"),
+ )
+ fmt.Println("")
+
+ // Sort nodes by name for consistent output
+ sortedNodes := make([]ClusterNodeDto, len(clusterStatus.Nodes))
+ copy(sortedNodes, clusterStatus.Nodes)
+ sort.Slice(sortedNodes, func(i, j int) bool {
+ return sortedNodes[i].Name < sortedNodes[j].Name
+ })
+
+ // Process each node
+ for i, node := range sortedNodes {
+ isLastNode := i == len(sortedNodes)-1
+
+ // Format node metrics
+ ramAlloc := fmt.Sprintf("%dMi", node.ResourcesAllocated.MemoryMib)
+
+ var ramUsage string
+ if node.MetricsUsage.MemoryMibRssUsage != nil && node.MetricsUsage.MemoryPercentRssUsage != nil {
+ ramUsage = fmt.Sprintf("%dMi(%d%%)", *node.MetricsUsage.MemoryMibRssUsage, *node.MetricsUsage.MemoryPercentRssUsage)
+ } else {
+ ramUsage = "--(--%)"
+ }
+
+ cpuAlloc := fmt.Sprintf("%dm", node.ResourcesAllocated.CpuMilli)
+
+ var cpuUsage string
+ if node.MetricsUsage.CpuMilliUsage != nil && node.MetricsUsage.CpuPercentUsage != nil {
+ cpuUsage = fmt.Sprintf("%dm(%d%%)", *node.MetricsUsage.CpuMilliUsage, *node.MetricsUsage.CpuPercentUsage)
+ } else {
+ cpuUsage = "--(--%)"
+ }
+
+ var diskUsage string
+ if node.MetricsUsage.DiskMibUsage != nil && node.MetricsUsage.DiskPercentUsage != nil {
+ diskUsage = fmt.Sprintf("%dMi(%d%%)", *node.MetricsUsage.DiskMibUsage, *node.MetricsUsage.DiskPercentUsage)
+ } else {
+ diskUsage = "--(--%)"
+ }
+
+ // Print node information
+ fmt.Printf("%-79s %-20s %-20s %-20s %-20s %-20s\n",
+ pterm.Bold.Sprintf("%s", node.Name),
+ pterm.Bold.Sprintf("%s", ramAlloc),
+ pterm.Bold.Sprintf("%s", ramUsage),
+ pterm.Bold.Sprintf("%s", cpuAlloc),
+ pterm.Bold.Sprintf("%s", cpuUsage),
+ pterm.Bold.Sprintf("%s", diskUsage))
+
+ // Sort pods by name for consistent output
+ sortedPods := make([]NodePodInfoDto, len(node.Pods))
+ copy(sortedPods, node.Pods)
+ sort.Slice(sortedPods, func(i, j int) bool {
+ return sortedPods[i].Name < sortedPods[j].Name
+ })
+
+ // Process each pod in the node
+ for j, pod := range sortedPods {
+ isLastPod := j == len(sortedPods)-1
+
+ // Determine the appropriate pod prefix symbol
+ var podPrefix string
+ if isLastPod {
+ podPrefix = "ââ "
+ } else {
+ podPrefix = "ââ "
+ }
+
+ // Format pod metrics
+ var podRamAlloc string
+ if pod.MemoryMibRequest != nil {
+ podRamAlloc = fmt.Sprintf("%dMi", *pod.MemoryMibRequest)
+ } else {
+ podRamAlloc = "--"
+ }
+
+ var podRamUsage string
+ if pod.MetricsUsage.MemoryMibRssUsage != nil && pod.MetricsUsage.MemoryPercentRssUsage != nil {
+ podRamUsage = fmt.Sprintf("%dMi(%d%%)", *pod.MetricsUsage.MemoryMibRssUsage, *pod.MetricsUsage.MemoryPercentRssUsage)
+ } else {
+ podRamUsage = "--(--%)"
+ }
+
+ var podCpuAlloc string
+ if pod.CpuMilliRequest != nil {
+ podCpuAlloc = fmt.Sprintf("%dm", *pod.CpuMilliRequest)
+ } else {
+ podCpuAlloc = "--"
+ }
+
+ var podCpuUsage string
+ if pod.MetricsUsage.CpuMilliUsage != nil && pod.MetricsUsage.CpuPercentUsage != nil {
+ podCpuUsage = fmt.Sprintf("%dm(%d%%)", *pod.MetricsUsage.CpuMilliUsage, *pod.MetricsUsage.CpuPercentUsage)
+ } else {
+ podCpuUsage = "--(--%)"
+ }
+
+ var podDiskUsage string
+ if pod.MetricsUsage.DiskMibUsage != nil && pod.MetricsUsage.DiskPercentUsage != nil {
+ podDiskUsage = fmt.Sprintf("%dMi(%d%%)", *pod.MetricsUsage.DiskMibUsage, *pod.MetricsUsage.DiskPercentUsage)
+ } else {
+ podDiskUsage = "--(--%)"
+ }
+
+ var podName string
+ if len(pod.ErrorContainerStatuses) > 0 {
+ podName = fmt.Sprintf("%-77s", pterm.Red(pod.Name))
+ } else {
+ podName = fmt.Sprintf("%-68s", pod.Name)
+ }
+
+ // Print pod information
+ fmt.Printf("%s%s %-12s %-12s %-12s %-12s %-12s\n",
+ podPrefix,
+ podName,
+ podRamAlloc,
+ podRamUsage,
+ podCpuAlloc,
+ podCpuUsage,
+ podDiskUsage,
+ )
+ }
+
+ // Add blank line between nodes
+ if !isLastNode {
+ fmt.Printf("\n")
+ }
+ }
+}
+
+type ClusterStatusRequest struct {
+ OrganizationID utils.Id `url:"organization"`
+ ClusterID utils.Id `url:"cluster"`
+}
+
+type ClusterStatusDto struct {
+ ComputedStatus ClusterComputedStatusDto `json:"computed_status"`
+ Nodes []ClusterNodeDto `json:"nodes"`
+ Pvcs []PvcInfoDto `json:"pvcs"`
+}
+
+type ClusterComputedStatusDto struct {
+ GlobalStatus ClusterStatusGlobalStatus `json:"global_status"`
+ QoveryComponentsInFailure []QoveryComponentInFailure `json:"qovery_components_in_failure"`
+ NodeWarnings map[string][]QoveryNodeFailure `json:"node_warnings"`
+ IsMaxNodesSizeReached bool `json:"is_max_nodes_size_reached"`
+ KubeVersionStatus QoveryClusterKubeVersionStatus `json:"kube_version_status"`
+}
+
+type ClusterStatusGlobalStatus string
+
+const (
+ ClusterStatusGlobalStatusRunning ClusterStatusGlobalStatus = "RUNNING"
+ ClusterStatusGlobalStatusWarning ClusterStatusGlobalStatus = "WARNING"
+ ClusterStatusGlobalStatusError ClusterStatusGlobalStatus = "ERROR"
+)
+
+type QoveryComponentInFailure struct {
+ Type string `json:"type"`
+ ComponentName string `json:"component_name"`
+ PodName string `json:"pod_name,omitempty"`
+ ContainerName string `json:"container_name,omitempty"`
+ Level QoveryComponentContainerStatusLevel `json:"level,omitempty"`
+ Reason *string `json:"reason,omitempty"`
+ Message *string `json:"message,omitempty"`
+}
+
+type PodInErrorValue struct {
+ ComponentName string `json:"component_name"`
+ PodName string `json:"pod_name"`
+ ContainerName string `json:"container_name"`
+ Level QoveryComponentContainerStatusLevel `json:"level"`
+ Reason *string `json:"reason"`
+ Message *string `json:"message"`
+ Type string `json:"type"`
+}
+
+type MissingComponentValue struct {
+ ComponentName string `json:"component_name"`
+ Type string `json:"type"`
+}
+
+type QoveryComponentContainerStatusIssue struct {
+ Level QoveryComponentContainerStatusLevel `json:"level"`
+ Reason *string `json:"reason"`
+ Message *string `json:"message"`
+}
+
+type QoveryNodeFailure struct {
+ Reason string `json:"reason"`
+ Message string `json:"message"`
+}
+
+type QoveryComponentContainerStatusLevel string
+
+const (
+ QoveryComponentContainerStatusLevelError QoveryComponentContainerStatusLevel = "ERROR"
+ QoveryComponentContainerStatusLevelWarning QoveryComponentContainerStatusLevel = "WARNING"
+)
+
+type QoveryClusterKubeVersionStatus struct {
+ Type string `json:"type"`
+ KubeVersion string `json:"kube_version,omitempty"`
+ ExpectedKubeVersion string `json:"expected_kube_version,omitempty"`
+}
+
+type KubeVersionStatusOkValue struct {
+ KubeVersion string `json:"kube_version"`
+ Type string `json:"type"`
+}
+
+type KubeVersionStatusDriftValue struct {
+ KubeVersion string `json:"kube_version"`
+ ExpectedKubeVersion string `json:"expected_kube_version"`
+ Type string `json:"type"`
+}
+
+type KubeVersionStatusUnknownValue struct {
+ Type string `json:"type"`
+}
+
+type ClusterNodeDto struct {
+ CreatedAt *uint64 `json:"created_at"`
+ Name string `json:"name"`
+ Architecture string `json:"architecture"`
+ InstanceType *string `json:"instance_type"`
+ KernelVersion string `json:"kernel_version"`
+ KubeletVersion string `json:"kubelet_version"`
+ OperatingSystem string `json:"operating_system"`
+ OsImage string `json:"os_image"`
+ Unschedulable bool `json:"unschedulable"`
+ ResourcesAllocatable NodeResourceDto `json:"resources_allocatable"`
+ ResourcesAllocated NodeResourceAllocatedDto `json:"resources_allocated"`
+ Taints []NodeTaintDto `json:"taints"`
+ Conditions []NodeConditionDto `json:"conditions"`
+ Labels map[string]string `json:"labels"`
+ Annotations map[string]string `json:"annotations"`
+ Addresses []NodeAddressDto `json:"addresses"`
+ Pods []NodePodInfoDto `json:"pods"`
+ MetricsUsage MetricsUsageDto `json:"metrics_usage"`
+}
+
+type NodeTaintDto struct {
+ Key string `json:"key"`
+ Value string `json:"value"`
+ Effect string `json:"effect"`
+}
+
+type NodeConditionDto struct {
+ Type string `json:"type"`
+ Status string `json:"status"`
+ LastHeartbeatTime *uint64 `json:"last_heartbeat_time"`
+ LastTransitionTime *uint64 `json:"last_transition_time"`
+ Reason string `json:"reason"`
+ Message string `json:"message"`
+}
+
+type NodeResourceDto struct {
+ CpuMilli uint64 `json:"cpu_milli"`
+ MemoryMib uint64 `json:"memory_mib"`
+ EphemeralStorageMib uint64 `json:"ephemeral_storage_mib"`
+ Pods uint64 `json:"pods"`
+}
+
+type NodeResourceAllocatedDto struct {
+ MemoryMib uint32 `json:"memory_mib"`
+ CpuMilli uint32 `json:"cpu_milli"`
+}
+
+type NodePodInfoDto struct {
+ CreatedAt *uint64 `json:"created_at"`
+ Name string `json:"name"`
+ Namespace string `json:"namespace"`
+ ErrorContainerStatuses []NodePodErrorStatusDto `json:"error_container_statuses"`
+ QoveryServiceInfo *PodQoveryServiceInfoDto `json:"qovery_service_info"`
+ CpuMilliRequest *uint32 `json:"cpu_milli_request"`
+ CpuMilliLimit *uint32 `json:"cpu_milli_limit"`
+ MemoryMibRequest *uint32 `json:"memory_mib_request"`
+ MemoryMibLimit *uint32 `json:"memory_mib_limit"`
+ MetricsUsage MetricsUsageDto `json:"metrics_usage"`
+ ImagesVersion map[string]string `json:"images_version"`
+ RestartCount uint32 `json:"restart_count"`
+}
+
+type NodePodErrorStatusDto struct {
+ ContainerName string `json:"container_name"`
+ Reason *string `json:"reason"`
+ Message *string `json:"message"`
+}
+
+type PodQoveryServiceInfoDto struct {
+ ProjectId string `json:"project_id"`
+ ProjectName string `json:"project_name"`
+ EnvironmentId string `json:"environment_id"`
+ EnvironmentName string `json:"environment_name"`
+ ServiceId string `json:"service_id"`
+ ServiceName string `json:"service_name"`
+}
+
+type MetricsUsageDto struct {
+ CpuMilliUsage *uint32 `json:"cpu_milli_usage"`
+ CpuPercentUsage *uint32 `json:"cpu_percent_usage"`
+ MemoryMibRssUsage *uint32 `json:"memory_mib_rss_usage"`
+ MemoryPercentRssUsage *uint32 `json:"memory_percent_rss_usage"`
+ MemoryMibWorkingSetUsage *uint32 `json:"memory_mib_working_set_usage"`
+ MemoryPercentWorkingSetUsage *uint32 `json:"memory_percent_working_set_usage"`
+ DiskMibUsage *uint32 `json:"disk_mib_usage"`
+ DiskPercentUsage *uint32 `json:"disk_percent_usage"`
+}
+
+type NodeAddressDto struct {
+ Type string `json:"type"`
+ Address string `json:"address"`
+}
+
+type PvcInfoDto struct {
+ Name string `json:"name"`
+ Namespace string `json:"namespace"`
+ PodName string `json:"pod_name"`
+ DiskMibUsage uint32 `json:"disk_mib_usage"`
+ DiskPercentUsage uint32 `json:"disk_percent_usage"`
+ DiskMibCapacity uint32 `json:"disk_mib_capacity"`
+ QoveryServiceInfo *PodQoveryServiceInfoDto `json:"qovery_service_info"`
+}
diff --git a/cmd/admin_cluster_update_dns_provider.go b/cmd/admin_cluster_update_dns_provider.go
new file mode 100644
index 00000000..394593b2
--- /dev/null
+++ b/cmd/admin_cluster_update_dns_provider.go
@@ -0,0 +1,137 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/pkg"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ dnsProvider string
+ dnsDomain string
+ cloudflareEmail string
+ cloudflareToken string
+ cloudflareProxied bool
+ qoveryApiUrl string
+ route53AccessKeyId string
+ route53SecretAccessKey string
+ route53Region string
+ route53HostedZoneId string
+)
+
+var adminClusterUpdateDnsProviderCmd = &cobra.Command{
+ Use: "update-dns-provider",
+ Short: "Update cluster DNS provider credentials and domain. Cluster and all apps need to be re-deployed after",
+ Long: `Update the DNS provider configuration for a cluster. This allows you to switch between or reconfigure
+DNS providers (Cloudflare, Qovery, or Route53). After updating, the cluster and all applications must be re-deployed.
+
+Examples:
+ # Update to Cloudflare
+ qovery admin cluster update-dns-provider --cluster-id --domain example.com \
+ --provider cloudflare --cloudflare-email user@example.com --cloudflare-token
+
+ # Update to Route53
+ qovery admin cluster update-dns-provider --cluster-id --domain example.com \
+ --provider route53 --route53-access-key-id --route53-secret-access-key \
+ --route53-region us-east-1 [--route53-hosted-zone-id ]
+
+ # Update to Qovery DNS
+ qovery admin cluster update-dns-provider --cluster-id --domain example.com \
+ --provider qovery --qovery-api-url https://dns.qovery.com`,
+ Run: func(cmd *cobra.Command, args []string) {
+ updateClusterDnsProvider()
+ },
+}
+
+func init() {
+ adminClusterUpdateDnsProviderCmd.Flags().StringVar(&clusterId, "cluster-id", "", "The cluster id to target (required)")
+ adminClusterUpdateDnsProviderCmd.Flags().StringVar(&dnsDomain, "domain", "", "The domain for the cluster (required)")
+ adminClusterUpdateDnsProviderCmd.Flags().StringVar(&dnsProvider, "provider", "", "DNS provider: cloudflare, qovery, or route53 (required)")
+
+ // Cloudflare flags
+ adminClusterUpdateDnsProviderCmd.Flags().StringVar(&cloudflareEmail, "cloudflare-email", "", "Cloudflare email")
+ adminClusterUpdateDnsProviderCmd.Flags().StringVar(&cloudflareToken, "cloudflare-token", "", "Cloudflare API token")
+ adminClusterUpdateDnsProviderCmd.Flags().BoolVar(&cloudflareProxied, "cloudflare-proxied", false, "Enable Cloudflare proxy")
+
+ // Qovery flags
+ adminClusterUpdateDnsProviderCmd.Flags().StringVar(&qoveryApiUrl, "qovery-api-url", "", "Qovery DNS API URL")
+
+ // Route53 flags
+ adminClusterUpdateDnsProviderCmd.Flags().StringVar(&route53AccessKeyId, "route53-access-key-id", "", "AWS Access Key ID for Route53")
+ adminClusterUpdateDnsProviderCmd.Flags().StringVar(&route53SecretAccessKey, "route53-secret-access-key", "", "AWS Secret Access Key for Route53")
+ adminClusterUpdateDnsProviderCmd.Flags().StringVar(&route53Region, "route53-region", "", "AWS Region for Route53")
+ adminClusterUpdateDnsProviderCmd.Flags().StringVar(&route53HostedZoneId, "route53-hosted-zone-id", "", "AWS Route53 Hosted Zone ID (optional)")
+
+ _ = adminClusterUpdateDnsProviderCmd.MarkFlagRequired("cluster-id")
+ _ = adminClusterUpdateDnsProviderCmd.MarkFlagRequired("domain")
+ _ = adminClusterUpdateDnsProviderCmd.MarkFlagRequired("provider")
+
+ adminClusterCmd.AddCommand(adminClusterUpdateDnsProviderCmd)
+}
+
+func updateClusterDnsProvider() {
+ if clusterId == "" {
+ utils.PrintlnError(nil)
+ utils.PrintlnInfo("cluster-id is required")
+ os.Exit(1)
+ }
+
+ if dnsDomain == "" {
+ utils.PrintlnError(nil)
+ utils.PrintlnInfo("domain is required")
+ os.Exit(1)
+ }
+
+ if dnsProvider == "" {
+ utils.PrintlnError(nil)
+ utils.PrintlnInfo("provider is required (cloudflare, qovery, or route53)")
+ os.Exit(1)
+ }
+
+ // Validate provider-specific flags
+ switch dnsProvider {
+ case "cloudflare":
+ if cloudflareEmail == "" || cloudflareToken == "" {
+ utils.PrintlnError(nil)
+ utils.PrintlnInfo("--cloudflare-email and --cloudflare-token are required for Cloudflare provider")
+ os.Exit(1)
+ }
+ case "qovery":
+ if qoveryApiUrl == "" {
+ utils.PrintlnError(nil)
+ utils.PrintlnInfo("--qovery-api-url is required for Qovery provider")
+ os.Exit(1)
+ }
+ case "route53":
+ if route53AccessKeyId == "" || route53SecretAccessKey == "" || route53Region == "" {
+ utils.PrintlnError(nil)
+ utils.PrintlnInfo("--route53-access-key-id, --route53-secret-access-key, and --route53-region are required for Route53 provider")
+ os.Exit(1)
+ }
+ default:
+ utils.PrintlnError(nil)
+ utils.PrintlnInfo("Invalid provider. Must be cloudflare, qovery, or route53")
+ os.Exit(1)
+ }
+
+ err := pkg.UpdateClusterDnsProvider(
+ clusterId,
+ dnsDomain,
+ dnsProvider,
+ cloudflareEmail,
+ cloudflareToken,
+ cloudflareProxied,
+ qoveryApiUrl,
+ route53AccessKeyId,
+ route53SecretAccessKey,
+ route53Region,
+ route53HostedZoneId,
+ )
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ utils.PrintlnInfo("DNS provider updated successfully. Please redeploy the cluster and all applications.")
+}
diff --git a/cmd/admin_cluster_update_domain.go b/cmd/admin_cluster_update_domain.go
new file mode 100644
index 00000000..28259b60
--- /dev/null
+++ b/cmd/admin_cluster_update_domain.go
@@ -0,0 +1,51 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/pkg"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ clusterDomain string
+)
+
+var adminClusterUpdateDomainCmd = &cobra.Command{
+ Use: "update-domain",
+ Short: "Update cluster domain/managed dns for a new one. Cluster and all apps need to be re-deployed after",
+ Run: func(cmd *cobra.Command, args []string) {
+ updateClusterDomain()
+ },
+}
+
+func init() {
+ adminClusterUpdateDomainCmd.Flags().StringVar(&clusterId, "cluster-id", "", "The cluster id to target")
+ adminClusterUpdateDomainCmd.Flags().StringVar(&clusterDomain, "domain", "", "The new domain for the cluster")
+ adminClusterCmd.AddCommand(adminClusterUpdateDomainCmd)
+}
+
+func updateClusterDomain() {
+ var err error
+ if clusterId == "" {
+ utils.PrintlnError(err)
+ utils.PrintlnInfo("cluster-id is required")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if clusterDomain == "" {
+ utils.PrintlnError(err)
+ utils.PrintlnInfo("domain is required")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = pkg.UpdateClusterDomainName(clusterId, clusterDomain)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ utils.PrintlnInfo("domain updated successfully")
+}
diff --git a/cmd/admin_cluster_update_kubeconfig.go b/cmd/admin_cluster_update_kubeconfig.go
new file mode 100644
index 00000000..1b24e99f
--- /dev/null
+++ b/cmd/admin_cluster_update_kubeconfig.go
@@ -0,0 +1,60 @@
+package cmd
+
+import (
+ "errors"
+ "os"
+
+ "github.com/qovery/qovery-cli/pkg"
+ "github.com/qovery/qovery-cli/pkg/cluster"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var adminClusterUpdateKubeconfigCmd = &cobra.Command{
+ Use: "kubeconfig",
+ Short: "Update cluster kubeconfig",
+ Run: func(cmd *cobra.Command, args []string) {
+ updateClusterKubeconfig()
+ },
+}
+
+func init() {
+ adminClusterUpdateKubeconfigCmd.Flags().StringVar(&organizationId, "organization-id", "", "The cluster's organization ")
+ adminClusterUpdateKubeconfigCmd.Flags().StringVar(&clusterId, "cluster-id", "", "The cluster id to target")
+ adminClusterUpdateKubeconfigCmd.Flags().StringVar(&clusterKubeconfig, "kubeconfig", "", "The cluster kubeconfig string value")
+ adminClusterCmd.AddCommand(adminClusterUpdateKubeconfigCmd)
+}
+
+func updateClusterKubeconfig() {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ // Allow this for self managed cluster only for the time being
+ cluster, err := cluster.NewClusterService(client, &promptuifactory.PromptUiFactoryImpl{}).GetClusterByID(organizationId, clusterId)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if cluster.Kubernetes == nil || (*cluster.Kubernetes != qovery.KUBERNETESENUM_SELF_MANAGED && *cluster.Kubernetes != qovery.KUBERNETESENUM_PARTIALLY_MANAGED) {
+ utils.PrintlnError(errors.New("kubeconfig update is supported for SELF MANAGED and PARTIALLY MANAGED clusters only"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = pkg.UpdateClusterKubeconfig(organizationId, clusterId, clusterKubeconfig)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
diff --git a/cmd/admin_delete_cluster.go b/cmd/admin_delete_cluster.go
new file mode 100644
index 00000000..6d87bd08
--- /dev/null
+++ b/cmd/admin_delete_cluster.go
@@ -0,0 +1,33 @@
+package cmd
+
+import (
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg"
+)
+
+var (
+ adminDeleteClusterCmd = &cobra.Command{
+ Use: "force-delete-cluster",
+ Short: "Force delete cluster by id (only Qovery DB side, without calling the engine)",
+ Run: func(cmd *cobra.Command, args []string) {
+ deleteClusterById()
+ },
+ }
+)
+
+func init() {
+ adminDeleteClusterCmd.Flags().StringVarP(&clusterId, "cluster", "c", "", "Cluster's id")
+ adminDeleteClusterCmd.Flags().BoolVarP(&dryRun, "disable-dry-run", "y", false, "Disable dry run mode")
+ orgaErr = adminDeleteClusterCmd.MarkFlagRequired("cluster")
+ adminCmd.AddCommand(adminDeleteClusterCmd)
+}
+
+func deleteClusterById() {
+ if orgaErr != nil {
+ log.Error("Invalid cluster Id")
+ } else {
+ pkg.DeleteClusterById(clusterId, dryRun)
+ }
+}
diff --git a/cmd/admin_delete_cluster_undeployed_in_error.go b/cmd/admin_delete_cluster_undeployed_in_error.go
new file mode 100644
index 00000000..d8bedb5c
--- /dev/null
+++ b/cmd/admin_delete_cluster_undeployed_in_error.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg"
+)
+
+var (
+ adminDeleteClusterUnDeployedInErrorCmd = &cobra.Command{
+ Use: "delete-cluster-undeployed-in-error",
+ Short: "Trigger deletion of all clusters not deployed once and that are in error",
+ Run: func(cmd *cobra.Command, args []string) {
+ deleteClusterUnDeployedInError()
+ },
+ }
+)
+
+func init() {
+ adminCmd.AddCommand(adminDeleteClusterUnDeployedInErrorCmd)
+}
+
+func deleteClusterUnDeployedInError() {
+ pkg.DeleteClusterUnDeployedInError()
+}
diff --git a/cmd/admin_delete_old_invalid_credentials_clusters.go b/cmd/admin_delete_old_invalid_credentials_clusters.go
new file mode 100644
index 00000000..85b65ff9
--- /dev/null
+++ b/cmd/admin_delete_old_invalid_credentials_clusters.go
@@ -0,0 +1,26 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/pkg"
+ "github.com/spf13/cobra"
+)
+
+var (
+ adminDeleteOldInvalidCredentialsClustersCmd = &cobra.Command{
+ Use: "force-delete-old-invalid-credentials-clusters",
+ Short: "Force delete clusters with invalid credentials with last updated date more thant n days",
+ Run: func(cmd *cobra.Command, args []string) {
+ deleteOldClustersWithInvalidCredentials()
+ },
+ }
+)
+
+func init() {
+ adminDeleteOldInvalidCredentialsClustersCmd.Flags().IntVarP(&ageInDay, "cluster-last-update-in-days", "d", 30, "cluster last update in days")
+ adminDeleteOldInvalidCredentialsClustersCmd.Flags().BoolVarP(&dryRun, "disable-dry-run", "y", false, "Disable dry run mode")
+ adminCmd.AddCommand(adminDeleteOldInvalidCredentialsClustersCmd)
+}
+
+func deleteOldClustersWithInvalidCredentials() {
+ pkg.DeleteOldClustersWithInvalidCredentials(ageInDay, dryRun)
+}
diff --git a/cmd/admin_delete_orga.go b/cmd/admin_delete_orga.go
index f15b934c..f6c3d26c 100644
--- a/cmd/admin_delete_orga.go
+++ b/cmd/admin_delete_orga.go
@@ -1,32 +1,66 @@
package cmd
import (
+ "strings"
+
"github.com/qovery/qovery-cli/pkg"
- log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
var (
+ organizationIds []string
+ allowFailedClusters bool
+
adminDeleteOrgaCmd = &cobra.Command{
Use: "delete",
- Short: "Delete organization by the cluster's id it owns",
+ Short: "Delete one or more organizations by their IDs",
+ Long: `Delete one or more organizations by providing their IDs.
+
+Examples:
+ # Delete a single organization
+ qovery admin organization delete --organization-id org-123
+
+ # Delete multiple organizations (comma-separated)
+ qovery admin organization delete --organization-id "org-123,org-456,org-789"
+
+ # Delete multiple organizations (repeated flag)
+ qovery admin organization delete --organization-id org-123 --organization-id org-456
+
+ # Mix both formats
+ qovery admin organization delete -o "org-123,org-456" -o org-789
+
+ # Allow deletion of organizations with failed clusters
+ qovery admin organization delete -o org-123 --allow-failed-clusters
+
+ # Disable dry-run to actually delete
+ qovery admin organization delete -o org-123 --disable-dry-run`,
Run: func(cmd *cobra.Command, args []string) {
- deleteOrganizationByClusterId()
+ deleteOrganizations()
},
}
)
func init() {
- adminDeleteOrgaCmd.Flags().StringVarP(&clusterId, "cluster", "c", "", "Cluster's id")
+ adminDeleteOrgaCmd.Flags().StringSliceVarP(&organizationIds, "organization-id", "o", []string{}, "Organization ID(s) to delete (comma-separated or repeated flag)")
+ adminDeleteOrgaCmd.Flags().BoolVarP(&allowFailedClusters, "allow-failed-clusters", "f", false, "Allow deletion of organizations with failed or non-deployed clusters")
adminDeleteOrgaCmd.Flags().BoolVarP(&dryRun, "disable-dry-run", "y", false, "Disable dry run mode")
- orgaErr = adminDeleteOrgaCmd.MarkFlagRequired("cluster")
+ _ = adminDeleteOrgaCmd.MarkFlagRequired("organization-id")
adminCmd.AddCommand(adminDeleteOrgaCmd)
}
-func deleteOrganizationByClusterId() {
- if orgaErr != nil {
- log.Error("Invalid cluster Id")
- } else {
- pkg.DeleteOrganizationByClusterId(clusterId, dryRun)
+func deleteOrganizations() {
+ // Parse comma-separated values in case user provides "id1,id2,id3"
+ var parsedIds []string
+ for _, id := range organizationIds {
+ // Split by comma and trim spaces
+ parts := strings.Split(id, ",")
+ for _, part := range parts {
+ trimmed := strings.TrimSpace(part)
+ if trimmed != "" {
+ parsedIds = append(parsedIds, trimmed)
+ }
+ }
}
+
+ pkg.DeleteOrganizations(parsedIds, allowFailedClusters, dryRun)
}
diff --git a/cmd/admin_delete_project.go b/cmd/admin_delete_project.go
new file mode 100644
index 00000000..ba31123a
--- /dev/null
+++ b/cmd/admin_delete_project.go
@@ -0,0 +1,32 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/pkg"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var (
+ adminDeleteProjectCmd = &cobra.Command{
+ Use: "force-delete-project",
+ Short: "Force delete project by id (only Qovery DB side, without calling the engine)",
+ Run: func(cmd *cobra.Command, args []string) {
+ deleteProjectById()
+ },
+ }
+)
+
+func init() {
+ adminDeleteProjectCmd.Flags().StringVarP(&projectId, "project", "p", "", "Project's id")
+ adminDeleteProjectCmd.Flags().BoolVarP(&dryRun, "disable-dry-run", "y", false, "Disable dry run mode")
+ orgaErr = adminDeleteProjectCmd.MarkFlagRequired("project")
+ adminCmd.AddCommand(adminDeleteProjectCmd)
+}
+
+func deleteProjectById() {
+ if orgaErr != nil {
+ log.Error("Invalid project Id")
+ } else {
+ pkg.DeleteProjectById(projectId, dryRun)
+ }
+}
diff --git a/cmd/admin_demo.go b/cmd/admin_demo.go
new file mode 100644
index 00000000..5f4e2eed
--- /dev/null
+++ b/cmd/admin_demo.go
@@ -0,0 +1,28 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminDemoCmd = &cobra.Command{
+ Use: "demo",
+ Short: "get errors logs for the demo",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+ }
+)
+
+func init() {
+ adminCmd.AddCommand(adminDemoCmd)
+}
diff --git a/cmd/admin_demo_get_logs.go b/cmd/admin_demo_get_logs.go
new file mode 100644
index 00000000..810876e3
--- /dev/null
+++ b/cmd/admin_demo_get_logs.go
@@ -0,0 +1,66 @@
+package cmd
+
+import (
+ "bytes"
+ "fmt"
+ log "github.com/sirupsen/logrus"
+ "io"
+ "net/http"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+type ListLogResponse struct {
+ Filename string `json:"filename"`
+ LastModified string `json:"last_modified"`
+}
+
+var (
+ adminDemoGetLogsCmd = &cobra.Command{
+ Use: "get-log",
+ Short: "retrieve a specific log",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ log.Fatal("You must specify a log filename as argument")
+ os.Exit(0)
+ }
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ url := fmt.Sprintf("%s/demoDebugLog", utils.GetAdminUrl())
+ req, _ := http.NewRequest(http.MethodGet, url, bytes.NewReader([]byte{}))
+ query := req.URL.Query()
+ query.Add("filename", args[0])
+ req.URL.RawQuery = query.Encode()
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+
+ response, err := http.DefaultClient.Do(req)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("error uploading debug logs: %s", err))
+ return
+ }
+
+ body, _ := io.ReadAll(response.Body)
+ if response.StatusCode != http.StatusOK {
+ utils.PrintlnError(fmt.Errorf("error uploading debug logs: %s %s", response.Status, body))
+ return
+ }
+
+ _ = os.WriteFile(args[0], body, 0640)
+ log.Info("file written to ", args[0])
+ },
+ }
+)
+
+func init() {
+ adminDemoCmd.AddCommand(adminDemoGetLogsCmd)
+}
diff --git a/cmd/admin_demo_list_logs.go b/cmd/admin_demo_list_logs.go
new file mode 100644
index 00000000..ede4e9c0
--- /dev/null
+++ b/cmd/admin_demo_list_logs.go
@@ -0,0 +1,71 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+type listLogResponse struct {
+ Filename string `json:"filename"`
+ LastModified string `json:"last_modified"`
+}
+
+var (
+ adminDemoListLogsCmd = &cobra.Command{
+ Use: "list-logs",
+ Short: "list error logs from the command demo up",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ url := fmt.Sprintf("%s/demoDebugLog", utils.GetAdminUrl())
+ req, _ := http.NewRequest(http.MethodGet, url, bytes.NewReader([]byte{}))
+ query := req.URL.Query()
+ orgaId, _ := cmd.Flags().GetString("organizationId")
+ if orgaId != "" {
+ query.Add("organization", orgaId)
+ }
+ req.URL.RawQuery = query.Encode()
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+
+ response, err := http.DefaultClient.Do(req)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("error uploading debug logs: %s", err))
+ return
+ }
+
+ body, _ := io.ReadAll(response.Body)
+ if response.StatusCode != http.StatusOK {
+ utils.PrintlnError(fmt.Errorf("error uploading debug logs: %s %s", response.Status, body))
+ return
+ }
+
+ var responseObject []listLogResponse
+ _ = json.Unmarshal(body, &responseObject)
+
+ var rows [][]string
+ for _, row := range responseObject {
+ rows = append(rows, []string{row.LastModified, row.Filename})
+ }
+ _ = utils.PrintTable([]string{"date", "filename"}, rows)
+ },
+ }
+)
+
+func init() {
+ adminDemoListLogsCmd.Flags().StringP("organizationId", "o", "", "Organization to filter on for listing")
+ adminDemoCmd.AddCommand(adminDemoListLogsCmd)
+}
diff --git a/cmd/admin_deploy.go b/cmd/admin_deploy.go
deleted file mode 100644
index 6f8c966b..00000000
--- a/cmd/admin_deploy.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package cmd
-
-import (
- "github.com/qovery/qovery-cli/pkg"
- log "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
-)
-
-var (
- adminDeployByIdCmd = &cobra.Command{
- Use: "deploy",
- Short: "Deploy cluster with its Id",
- Run: func(cmd *cobra.Command, args []string) {
- deployClusterById()
- },
- }
-)
-
-func init() {
- adminDeployByIdCmd.Flags().StringVarP(&clusterId, "cluster", "c", "", "Cluster's id")
- adminDeployByIdCmd.Flags().BoolVarP(&dryRun, "disable-dry-run", "y", false, "Disable dry run mode")
- orgaErr = adminDeployByIdCmd.MarkFlagRequired("cluster")
- adminCmd.AddCommand(adminDeployByIdCmd)
-}
-
-func deployClusterById() {
- if orgaErr != nil {
- log.Error("Invalid cluster Id")
- } else {
- pkg.DeployById(clusterId, dryRun)
- }
-}
diff --git a/cmd/admin_deploy_all.go b/cmd/admin_deploy_all.go
deleted file mode 100644
index f525cde4..00000000
--- a/cmd/admin_deploy_all.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package cmd
-
-import (
- "github.com/qovery/qovery-cli/pkg"
- "github.com/spf13/cobra"
-)
-
-var (
- adminDeployAllCmd = &cobra.Command{
- Use: "deploy_all",
- Short: "Deploy all customers clusters",
- Run: func(cmd *cobra.Command, args []string) {
- deployAllClusters()
- },
- }
-)
-
-func init() {
- adminDeployAllCmd.Flags().BoolVarP(&dryRun, "disable-dry-run", "y", false, "Disable dry run mode")
- adminCmd.AddCommand(adminDeployAllCmd)
-}
-
-func deployAllClusters() {
- pkg.DeployAll(dryRun)
-}
diff --git a/cmd/admin_deploy_failed_force_internal_error.go b/cmd/admin_deploy_failed_force_internal_error.go
new file mode 100644
index 00000000..4a22a5e1
--- /dev/null
+++ b/cmd/admin_deploy_failed_force_internal_error.go
@@ -0,0 +1,35 @@
+package cmd
+
+import (
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+ "os"
+ "time"
+
+ "github.com/qovery/qovery-cli/pkg"
+)
+
+var (
+ adminForceFailedDeploymentsToInternalErrorCmd = &cobra.Command{
+ Use: "force-failed-deployments-to-internal-error",
+ Short: "Force the status of environment deployments in a non-final state to INTERNAL_ERROR, and also force any of the deployment statuses associated",
+ Run: func(cmd *cobra.Command, args []string) {
+ safeDuration, _ := cmd.Flags().GetString("safeguardDuration")
+ duration, err := time.ParseDuration(safeDuration)
+ if err != nil {
+ log.Errorf("Could not parse duration : %s. Got %s", err, safeDuration)
+ os.Exit(1)
+ }
+ forceFailedDeploymentsToInternalErrorStatus(duration)
+ },
+ }
+)
+
+func init() {
+ adminForceFailedDeploymentsToInternalErrorCmd.Flags().StringP("safeguardDuration", "d", "20m", "wait at least the duration for env in non final state that haven't been updated to mark them as failed")
+ adminCmd.AddCommand(adminForceFailedDeploymentsToInternalErrorCmd)
+}
+
+func forceFailedDeploymentsToInternalErrorStatus(duration time.Duration) {
+ pkg.ForceFailedDeploymentsToInternalErrorStatus(duration)
+}
diff --git a/cmd/admin_enable_user_connect.go b/cmd/admin_enable_user_connect.go
new file mode 100644
index 00000000..04d94aa0
--- /dev/null
+++ b/cmd/admin_enable_user_connect.go
@@ -0,0 +1,163 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ userEmail string
+ provider string
+ adminEnableUserSignupCmd = &cobra.Command{
+ Use: "enable-user-connect",
+ Short: "Allow a new user to connect after sign up",
+ Long: `Allow a new user to connect after sign up with the specified email and authentication provider.
+
+Example:
+ qovery admin enable-user-connect --user-email "user@example.com"
+ qovery admin enable-user-connect --user-email "user@example.com" --provider "github"
+`,
+ Run: func(cmd *cobra.Command, args []string) {
+ enableUserSignup()
+ },
+ }
+)
+
+func init() {
+ adminEnableUserSignupCmd.Flags().StringVarP(&userEmail, "user-email", "e", "", "User email address (required)")
+ adminEnableUserSignupCmd.Flags().StringVarP(&provider, "provider", "p", "", "Authentication provider (github, gitlab, bitbucket, microsoft, google)")
+ if err := adminEnableUserSignupCmd.MarkFlagRequired("user-email"); err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to mark flag as required: %w", err))
+ os.Exit(1)
+ }
+ adminCmd.AddCommand(adminEnableUserSignupCmd)
+}
+
+type EnableUserSignupRequest struct {
+ UserEmail string `json:"user_email"`
+ Provider string `json:"provider,omitempty"`
+}
+
+// Provider enum
+
+type Provider string
+
+const (
+ ProviderGithub Provider = "GITHUB"
+ ProviderGitlab Provider = "GITLAB"
+ ProviderBitbucket Provider = "BITBUCKET"
+ ProviderMicrosoft Provider = "MICROSOFT"
+ ProviderGoogle Provider = "GOOGLE"
+)
+
+var validProviders = map[string]Provider{
+ "github": ProviderGithub,
+ "gitlab": ProviderGitlab,
+ "bitbucket": ProviderBitbucket,
+ "microsoft": ProviderMicrosoft,
+ "google": ProviderGoogle,
+}
+
+func (p Provider) String() string {
+ return string(p)
+}
+
+func parseProvider(input string) (Provider, bool) {
+ p, ok := validProviders[strings.ToLower(input)]
+ return p, ok
+}
+
+func enableUserSignup() {
+ // Validate required fields
+ if userEmail == "" {
+ utils.PrintlnError(fmt.Errorf("user email is required"))
+ os.Exit(1)
+ }
+
+ var providerEnum Provider
+ if provider != "" {
+ var ok bool
+ providerEnum, ok = parseProvider(provider)
+ if !ok {
+ // Show valid options in error
+ var opts []string
+ for k := range validProviders {
+ opts = append(opts, k)
+ }
+ utils.PrintlnError(fmt.Errorf("invalid provider '%s'. Valid values are: %v", provider, opts))
+ os.Exit(1)
+ }
+ }
+
+ // Get access token
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ // Prepare request payload
+ payload := EnableUserSignupRequest{
+ UserEmail: userEmail,
+ }
+ if provider != "" {
+ payload.Provider = providerEnum.String()
+ }
+
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to marshal payload: %w", err))
+ os.Exit(1)
+ }
+
+ // Create HTTP request
+ url := fmt.Sprintf("%s/enableUserSignUp", utils.GetAdminUrl())
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payloadBytes))
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to create request: %w", err))
+ os.Exit(1)
+ }
+
+ // Set headers
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Execute request
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to execute request: %w", err))
+ os.Exit(1)
+ }
+ defer func() {
+ if err := res.Body.Close(); err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to close response body: %w", err))
+ }
+ }()
+
+ // Read response body
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to read response body: %w", err))
+ os.Exit(1)
+ }
+
+ // Handle response based on status code
+ if res.StatusCode == http.StatusOK {
+ utils.Println("â
User signup enabled successfully")
+ if len(body) > 0 {
+ utils.Println(fmt.Sprintf("Response: %s", string(body)))
+ }
+ } else {
+ utils.PrintlnError(fmt.Errorf("â failed to enable user signup: %s - %s", res.Status, string(body)))
+ os.Exit(1)
+ }
+}
diff --git a/cmd/admin_encrypt_secret.go b/cmd/admin_encrypt_secret.go
new file mode 100644
index 00000000..7d1dbb71
--- /dev/null
+++ b/cmd/admin_encrypt_secret.go
@@ -0,0 +1,95 @@
+package cmd
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ messageToEncrypt string
+ adminSecretEncryptCmd = &cobra.Command{
+ Use: "encrypt-secret",
+ Short: "Encrypt a clear text message as a secret that can be used in core DB",
+ Run: func(cmd *cobra.Command, args []string) {
+ encryptSecret()
+ },
+ }
+)
+
+func init() {
+ adminSecretEncryptCmd.Flags().StringVarP(&organizationId, "organization-id", "o", "", "Organization ID of which the secret need to be encrypted of")
+ adminSecretEncryptCmd.Flags().StringVarP(&messageToEncrypt, "message", "m", "", "The message/value to encrypt")
+ adminCmd.AddCommand(adminSecretEncryptCmd)
+}
+
+func encryptSecret() {
+ var err error
+ if organizationId == "" {
+ utils.PrintlnInfo("organization-id is required")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if messageToEncrypt == "" {
+ utils.PrintlnInfo("message is required")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secret, err := callEncryptSecret(organizationId, messageToEncrypt)
+ utils.CheckError(err)
+ utils.PrintlnInfo(messageToEncrypt + " ==> " + secret)
+}
+
+func callEncryptSecret(organizationId string, secret string) (string, error) {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return "", fmt.Errorf("failed to get access token: %w", err)
+ }
+
+ // Build URL with proper escaping
+ u, err := url.Parse(utils.GetAdminUrl())
+ if err != nil {
+ return "", fmt.Errorf("invalid admin URL: %w", err)
+ }
+ u.Path = path.Join(u.Path, "organization", organizationId, "secret")
+ req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(secret))
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Create client with timeout
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ }
+
+ res, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+ defer func() { _ = res.Body.Close() }()
+
+ // Check status code
+ if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent {
+ return "", fmt.Errorf("failed to encrypt secret (status=%d)",
+ res.StatusCode)
+ }
+
+ secretBytes, err := io.ReadAll(res.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read body: %w", err)
+ }
+ return string(secretBytes), nil
+}
diff --git a/cmd/admin_enterprise_connection.go b/cmd/admin_enterprise_connection.go
new file mode 100644
index 00000000..6227bff8
--- /dev/null
+++ b/cmd/admin_enterprise_connection.go
@@ -0,0 +1,36 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminEnterpriseConnectionCmd = &cobra.Command{
+ Use: "enterprise-connection",
+ Short: "Manage enterprise connections",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+ }
+ enterpriseConnectionName string
+ enterpriseConnectionOrganizationId string
+)
+
+func init() {
+ adminCmd.AddCommand(adminEnterpriseConnectionCmd)
+}
+
+type EnterpriseConnection struct {
+ OrganizationID string `json:"organization_id"`
+ ConnectionName string `json:"connection_name"`
+ DefaultRole string `json:"default_role"`
+}
diff --git a/cmd/admin_enterprise_connection_create.go b/cmd/admin_enterprise_connection_create.go
new file mode 100644
index 00000000..0962f786
--- /dev/null
+++ b/cmd/admin_enterprise_connection_create.go
@@ -0,0 +1,89 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/qovery/qovery-cli/utils"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var (
+ adminEnterpriseConnectionCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create a new enterprise connection",
+ Run: func(cmd *cobra.Command, args []string) {
+ createEnterpriseConnection()
+ },
+ }
+)
+
+func init() {
+ adminEnterpriseConnectionCreateCmd.Flags().StringVarP(&enterpriseConnectionName, "connection-name", "c", "", "The connection name configured on Auth0 side for the target client")
+ adminEnterpriseConnectionCreateCmd.Flags().StringVarP(&enterpriseConnectionOrganizationId, "organization-id", "o", "", "The organization of the target client")
+
+ _ = adminEnterpriseConnectionCreateCmd.MarkFlagRequired("connection-name")
+ _ = adminEnterpriseConnectionCreateCmd.MarkFlagRequired("organization-id")
+
+ adminEnterpriseConnectionCmd.AddCommand(adminEnterpriseConnectionCreateCmd)
+}
+
+func createEnterpriseConnection() {
+ // Retrieve access token for authorization
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ // Prepare payload with required fields
+ payloadMap := map[string]string{
+ "organization_id": enterpriseConnectionOrganizationId,
+ "connection_name": enterpriseConnectionName,
+ }
+ payload, err := json.Marshal(payloadMap)
+ checkError(err)
+
+ // Build request
+ url := fmt.Sprintf("%s/enterpriseconnection", utils.GetAdminUrl())
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
+ if err != nil {
+ log.Fatal(err)
+ }
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Execute request
+ res, err := http.DefaultClient.Do(req)
+ checkError(err)
+ defer func() { _ = res.Body.Close() }()
+
+ // Read response
+ body, _ := io.ReadAll(res.Body)
+
+ // If not created, print the error message returned
+ if res.StatusCode != http.StatusCreated {
+ utils.PrintlnError(errors.New(string(body)))
+ return
+ }
+
+ // Parse response as single EnterpriseConnection object
+ var createdConnection EnterpriseConnection
+ if err := json.Unmarshal(body, &createdConnection); err != nil {
+ utils.PrintlnError(err)
+ return
+ }
+
+ // Display created connection using PrintTable
+ var data [][]string
+ data = append(data, []string{
+ createdConnection.OrganizationID,
+ createdConnection.ConnectionName,
+ createdConnection.DefaultRole,
+ })
+
+ err = utils.PrintTable([]string{"Organization ID", "Connection Name", "Default Role"}, data)
+ checkError(err)
+}
diff --git a/cmd/admin_enterprise_connection_delete.go b/cmd/admin_enterprise_connection_delete.go
new file mode 100644
index 00000000..419c814b
--- /dev/null
+++ b/cmd/admin_enterprise_connection_delete.go
@@ -0,0 +1,64 @@
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ adminEnterpriseConnectionDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete an enterprise connection",
+ Run: func(cmd *cobra.Command, args []string) {
+ deleteEnterpriseConnection()
+ },
+ }
+)
+
+func init() {
+ adminEnterpriseConnectionDeleteCmd.Flags().StringVarP(&enterpriseConnectionName, "connection-name", "c", "", "The connection name configured on Auth0 side for the target client")
+ adminEnterpriseConnectionDeleteCmd.Flags().StringVarP(&enterpriseConnectionOrganizationId, "organization-id", "o", "", "The organization of the target client")
+
+ _ = adminEnterpriseConnectionDeleteCmd.MarkFlagRequired("connection-name")
+ _ = adminEnterpriseConnectionDeleteCmd.MarkFlagRequired("organization-id")
+
+ adminEnterpriseConnectionCmd.AddCommand(adminEnterpriseConnectionDeleteCmd)
+}
+
+func deleteEnterpriseConnection() {
+ // Retrieve access token for authorization
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ // Build URL
+ cn := url.PathEscape(enterpriseConnectionName)
+ oid := url.QueryEscape(enterpriseConnectionOrganizationId)
+
+ url := fmt.Sprintf("%s/enterpriseconnection/%s?organization_id=%s", utils.GetAdminUrl(), cn, oid)
+
+ // Prepare request
+ req, err := http.NewRequest(http.MethodDelete, url, nil)
+ checkError(err)
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Execute request
+ res, err := http.DefaultClient.Do(req)
+ checkError(err)
+ defer func() { _ = res.Body.Close() }()
+
+ // Read response
+ body, _ := io.ReadAll(res.Body)
+
+ // If not accepted, print the error message returned
+ if res.StatusCode != http.StatusAccepted {
+ utils.PrintlnError(errors.New(string(body)))
+ return
+ }
+}
diff --git a/cmd/admin_enterprise_connection_list.go b/cmd/admin_enterprise_connection_list.go
new file mode 100644
index 00000000..970f623b
--- /dev/null
+++ b/cmd/admin_enterprise_connection_list.go
@@ -0,0 +1,87 @@
+package cmd
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ adminEnterpriseConnectionListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List enterprise connections by connection name",
+ Run: func(cmd *cobra.Command, args []string) {
+ listEnterpriseConnections()
+ },
+ }
+)
+
+func init() {
+ adminEnterpriseConnectionListCmd.Flags().StringVarP(&enterpriseConnectionName, "connection-name", "c", "", "The connection name configured on Auth0 side for the target client")
+ _ = adminEnterpriseConnectionListCmd.MarkFlagRequired("connection-name")
+
+ adminEnterpriseConnectionCmd.AddCommand(adminEnterpriseConnectionListCmd)
+}
+
+func listEnterpriseConnections() {
+ // Retrieve access token for authorization
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ // Build URL
+ cn := url.PathEscape(enterpriseConnectionName)
+
+ // Real URL example:
+ url := fmt.Sprintf("%s/enterpriseconnection/%s", utils.GetAdminUrl(), cn)
+
+ // Prepare request
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ checkError(err)
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Execute request
+ res, err := http.DefaultClient.Do(req)
+ checkError(err)
+ defer func() { _ = res.Body.Close() }()
+
+ // Read response
+ body, _ := io.ReadAll(res.Body)
+
+ // If not OK, print the error message returned
+ if res.StatusCode != http.StatusOK {
+ utils.PrintlnError(errors.New(string(body)))
+ return
+ }
+
+ wrapped := struct {
+ Results []EnterpriseConnection `json:"results"`
+ }{}
+
+ if err := json.Unmarshal(body, &wrapped); err != nil {
+ utils.PrintlnError(err)
+ return
+ }
+
+ list := wrapped.Results
+
+ // Display results using PrintTable
+ var data [][]string
+
+ for _, ec := range list {
+ data = append(data, []string{
+ ec.OrganizationID,
+ ec.ConnectionName,
+ ec.DefaultRole,
+ })
+ }
+
+ err = utils.PrintTable([]string{"Organization ID", "Connection Name", "Default Role"}, data)
+ checkError(err)
+}
diff --git a/cmd/admin_jw_qovery_usage_create.go b/cmd/admin_jw_qovery_usage_create.go
new file mode 100644
index 00000000..83ead644
--- /dev/null
+++ b/cmd/admin_jw_qovery_usage_create.go
@@ -0,0 +1,112 @@
+package cmd
+
+import (
+ "bytes"
+ "fmt"
+ "github.com/go-jose/go-jose/v4/json"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+ "io"
+ "net/http"
+ "os"
+ "text/tabwriter"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminJwtForQoveryUsageCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create a Jwt for Qovery usage",
+ Run: func(cmd *cobra.Command, args []string) {
+ createJwtForQoveryUsage()
+ },
+ }
+)
+
+func init() {
+ adminJwtForQoveryUsageCreateCmd.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "Cluster's id")
+ adminJwtForQoveryUsageCreateCmd.Flags().StringVarP(&organizationId, "organization-id", "", "", "Organization's id")
+ adminJwtForQoveryUsageCreateCmd.Flags().StringVarP(&rootDns, "root-dns", "", "", "root dns")
+ adminJwtForQoveryUsageCreateCmd.Flags().StringVarP(&additionalClaims, "additional-claims", "", "{}", "Additional claims in JSON format (e.g., '{\"key1\":\"value1\",\"key2\":\"value2\"}')")
+ adminJwtForQoveryUsageCreateCmd.Flags().StringVarP(&description, "description", "d", "", "Description of the JWT")
+
+ adminJwtForQoveryUsageCmd.AddCommand(adminJwtForQoveryUsageCreateCmd)
+}
+
+func createJwtForQoveryUsage() {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ var claimsMap map[string]string
+ err = json.Unmarshal([]byte(additionalClaims), &claimsMap)
+ if err != nil {
+ fmt.Printf("Error when parsing additional-claims : %v\n", err)
+ return
+ }
+
+ type Payload struct {
+ OrganizationId string `json:"organization_id"`
+ ClusterId string `json:"cluster_id"`
+ RootDns string `json:"root_dns"`
+ AdditionalClaims map[string]string `json:"additional_claims"`
+ Description string `json:"description"`
+ }
+
+ var payload, _ = json.Marshal(Payload{
+ ClusterId: clusterId,
+ OrganizationId: organizationId,
+ RootDns: rootDns,
+ AdditionalClaims: claimsMap,
+ Description: description,
+ })
+
+ url := fmt.Sprintf("%s/jwts", utils.GetAdminUrl())
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
+ if err != nil {
+ log.Fatal(err)
+ }
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ body, _ := io.ReadAll(res.Body)
+ if res.StatusCode != http.StatusOK {
+ utils.PrintlnError(fmt.Errorf("error uploading debug logs: %s %s", res.Status, body))
+ return
+ }
+
+ jwtForQoveryUsage := struct {
+ KeyId string `json:"key_id"`
+ Description string `json:"description"`
+ Jwt string `json:"decrypted_jwt"`
+ CreatedAt string `json:"created_at"`
+ }{}
+
+ if err := json.Unmarshal(body, &jwtForQoveryUsage); err != nil {
+ log.Fatal(err)
+ }
+ _, jwtPayload, err := DecodeJWT(jwtForQoveryUsage.Jwt)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
+
+ _, _ = fmt.Fprintln(w, "Field\t | Value")
+ _, _ = fmt.Fprintln(w, "------\t | ------")
+
+ _, _ = fmt.Fprintf(w, "key_id\t | %s\n", jwtForQoveryUsage.KeyId)
+ _, _ = fmt.Fprintf(w, "description\t | %s\n", jwtForQoveryUsage.Description)
+ _, _ = fmt.Fprintf(w, "jwt payload\t | %s\n", jwtPayload)
+ _, _ = fmt.Fprintf(w, "jwt\t | %s\n", jwtForQoveryUsage.Jwt)
+ _, _ = fmt.Fprintf(w, "created_at\t | %s\n", jwtForQoveryUsage.CreatedAt)
+ _ = w.Flush()
+}
diff --git a/cmd/admin_jw_qovery_usage_delete.go b/cmd/admin_jw_qovery_usage_delete.go
new file mode 100644
index 00000000..e99130d9
--- /dev/null
+++ b/cmd/admin_jw_qovery_usage_delete.go
@@ -0,0 +1,59 @@
+package cmd
+
+import (
+ "fmt"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+ "net/http"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminJwtForQoveryUsageDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete a Jwt for Qovery Usage",
+ Run: func(cmd *cobra.Command, args []string) {
+ deleteJwtForQoveryUsage()
+ },
+ }
+)
+
+func init() {
+ adminJwtForQoveryUsageDeleteCmd.Flags().StringVarP(&jwtKid, "kid", "", "", "Cluster's id")
+
+ adminJwtForQoveryUsageCmd.AddCommand(adminJwtForQoveryUsageDeleteCmd)
+
+}
+
+func deleteJwtForQoveryUsage() {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ url := fmt.Sprintf("%s/jwts/%s", utils.GetAdminUrl(), jwtKid)
+ req, err := http.NewRequest(http.MethodDelete, url, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if res == nil {
+ utils.PrintlnError(fmt.Errorf("error sending delete HTTP request"))
+ return
+ }
+
+ if res.StatusCode != http.StatusNoContent {
+ utils.PrintlnError(fmt.Errorf("error: %s", res.Status))
+ return
+ }
+
+ if err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/admin_jw_qovery_usage_list.go b/cmd/admin_jw_qovery_usage_list.go
new file mode 100644
index 00000000..0ab2d52e
--- /dev/null
+++ b/cmd/admin_jw_qovery_usage_list.go
@@ -0,0 +1,109 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/golang-jwt/jwt/v5"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+ "io"
+ "net/http"
+ "os"
+ "text/tabwriter"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminJwtForQoveryUsageListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List Jwt for Qovery usage",
+ Run: func(cmd *cobra.Command, args []string) {
+ listJwtsForQoveryUsage()
+ },
+ }
+)
+
+func init() {
+ adminJwtForQoveryUsageListCmd.Flags()
+
+ adminJwtForQoveryUsageCmd.AddCommand(adminJwtForQoveryUsageListCmd)
+
+}
+
+func listJwtsForQoveryUsage() {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ url := fmt.Sprintf("%s/jwts", utils.GetAdminUrl())
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ body, _ := io.ReadAll(res.Body)
+ if res.StatusCode != http.StatusOK {
+ utils.PrintlnError(fmt.Errorf("error uploading debug logs: %s %s", res.Status, body))
+ return
+ }
+
+ resp := struct {
+ Results []struct {
+ KeyId string `json:"key_id"`
+ Description string `json:"description"`
+ Jwt string `json:"decrypted_jwt"`
+ CreatedAt string `json:"created_at"`
+ } `json:"results"`
+ }{}
+ if err := json.Unmarshal(body, &resp); err != nil {
+ log.Fatal(err)
+ }
+
+ w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
+ for idx, jwtForQoveryUsage := range resp.Results {
+ _, jwtPayload, err := DecodeJWT(jwtForQoveryUsage.Jwt)
+ if err != nil {
+ log.Fatal(err)
+ }
+ _, _ = fmt.Fprintln(w, "\t")
+ _, _ = fmt.Fprintln(w, "Field\t | Value")
+ _, _ = fmt.Fprintln(w, "------\t | ------")
+
+ _, _ = fmt.Fprintf(w, "index\t | %s\n", fmt.Sprintf("%d", idx+1))
+ _, _ = fmt.Fprintf(w, "key_id\t | %s\n", jwtForQoveryUsage.KeyId)
+ _, _ = fmt.Fprintf(w, "description\t | %s\n", jwtForQoveryUsage.Description)
+ _, _ = fmt.Fprintf(w, "jwt payload\t | %s\n", jwtPayload)
+ _, _ = fmt.Fprintf(w, "jwt\t | %s\n", jwtForQoveryUsage.Jwt)
+ _, _ = fmt.Fprintf(w, "created_at\t | %s\n", jwtForQoveryUsage.CreatedAt)
+ }
+ _ = w.Flush()
+}
+
+func DecodeJWT(tokenString string) (string, string, error) {
+ token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{})
+ if err != nil {
+ return "", "", fmt.Errorf("failed to parse token: %w", err)
+ }
+
+ headerJSON, err := json.Marshal(token.Header)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to marshal header: %w", err)
+ }
+
+ claimsJSON, err := json.Marshal(token.Claims)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to marshal claims: %w", err)
+ }
+
+ return string(headerJSON), string(claimsJSON), nil
+}
diff --git a/cmd/admin_jwt.go b/cmd/admin_jwt.go
new file mode 100644
index 00000000..5757e16a
--- /dev/null
+++ b/cmd/admin_jwt.go
@@ -0,0 +1,28 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminJwtCmd = &cobra.Command{
+ Use: "jwt",
+ Short: "Manage JWT associated to clusters",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+ }
+)
+
+func init() {
+ adminCmd.AddCommand(adminJwtCmd)
+}
diff --git a/cmd/admin_jwt_create.go b/cmd/admin_jwt_create.go
new file mode 100644
index 00000000..6aeaec61
--- /dev/null
+++ b/cmd/admin_jwt_create.go
@@ -0,0 +1,82 @@
+package cmd
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "text/tabwriter"
+
+ "github.com/go-jose/go-jose/v4/json"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminJwtCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create a Jwt for a cluster",
+ Run: func(cmd *cobra.Command, args []string) {
+ createJwt()
+ },
+ }
+)
+
+func init() {
+ adminJwtCreateCmd.Flags().StringVarP(&clusterId, "cluster", "c", "", "Cluster's id")
+
+ adminJwtCmd.AddCommand(adminJwtCreateCmd)
+
+}
+
+func createJwt() {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ url := fmt.Sprintf("%s/clusters/%s/jwts", utils.GetAdminUrl(), clusterId)
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer([]byte("{ }")))
+ if err != nil {
+ log.Fatal(err)
+ }
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ body, _ := io.ReadAll(res.Body)
+ if res.StatusCode != http.StatusOK {
+ utils.PrintlnError(fmt.Errorf("error uploading debug logs: %s %s", res.Status, body))
+ return
+ }
+
+ jwt := struct {
+ KeyId string `json:"key_id"`
+ ClusterId string `json:"cluster_id"`
+ CreatedAt string `json:"created_at"`
+ }{}
+
+ if err := json.Unmarshal(body, &jwt); err != nil {
+ log.Fatal(err)
+ }
+
+ w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
+ format := "%s\t | %s\t | %s\t | %s\n"
+ if _, err := fmt.Fprintf(w, format, "", "cluster_id", "key_id", "created_at"); err != nil {
+ log.Fatal(err)
+ }
+ if _, err := fmt.Fprintf(w, format, fmt.Sprintf("%d", 1), jwt.ClusterId, jwt.KeyId, jwt.CreatedAt); err != nil {
+ log.Fatal(err)
+ }
+ if err := w.Flush(); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/admin_jwt_delete.go b/cmd/admin_jwt_delete.go
new file mode 100644
index 00000000..8000a16b
--- /dev/null
+++ b/cmd/admin_jwt_delete.go
@@ -0,0 +1,54 @@
+package cmd
+
+import (
+ "fmt"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+ "net/http"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminJwtDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete a Jwt",
+ Run: func(cmd *cobra.Command, args []string) {
+ deleteJwt()
+ },
+ }
+)
+
+func init() {
+ adminJwtDeleteCmd.Flags().StringVarP(&jwtKid, "kid", "", "", "Cluster's id")
+
+ adminJwtCmd.AddCommand(adminJwtDeleteCmd)
+
+}
+
+func deleteJwt() {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ url := fmt.Sprintf("%s/clusters/jwts/%s", utils.GetAdminUrl(), jwtKid)
+ req, err := http.NewRequest(http.MethodDelete, url, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if res.StatusCode != http.StatusNoContent {
+ utils.PrintlnError(fmt.Errorf("error: %s", res.Status))
+ return
+ }
+
+ if err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/admin_jwt_list.go b/cmd/admin_jwt_list.go
new file mode 100644
index 00000000..41e2d25c
--- /dev/null
+++ b/cmd/admin_jwt_list.go
@@ -0,0 +1,85 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "text/tabwriter"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminJwtListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List Jwt of a cluster",
+ Run: func(cmd *cobra.Command, args []string) {
+ listJwts()
+ },
+ }
+)
+
+func init() {
+ adminJwtListCmd.Flags().StringVarP(&clusterId, "cluster", "c", "", "Cluster's id")
+
+ adminJwtCmd.AddCommand(adminJwtListCmd)
+
+}
+
+func listJwts() {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ url := fmt.Sprintf("%s/clusters/%s/jwts", utils.GetAdminUrl(), clusterId)
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ body, _ := io.ReadAll(res.Body)
+ if res.StatusCode != http.StatusOK {
+ utils.PrintlnError(fmt.Errorf("error uploading debug logs: %s %s", res.Status, body))
+ return
+ }
+
+ resp := struct {
+ Results []struct {
+ ClusterId string `json:"cluster_id"`
+ KeyId string `json:"key_id"`
+ CreatedAt string `json:"created_at"`
+ } `json:"results"`
+ }{}
+
+ if err := json.Unmarshal(body, &resp); err != nil {
+ log.Fatal(err)
+ }
+
+ w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
+ format := "%s\t | %s\t | %s\t | %s\n"
+ if _, err := fmt.Fprintf(w, format, "", "cluster_id", "key_id", "created_at"); err != nil {
+ log.Fatal(err)
+ }
+ for idx, jwt := range resp.Results {
+ if _, err := fmt.Fprintf(w, format, fmt.Sprintf("%d", idx+1), jwt.ClusterId, jwt.KeyId, jwt.CreatedAt); err != nil {
+ log.Fatal(err)
+ }
+ }
+ if err := w.Flush(); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/admin_jwt_qovery_usage.go b/cmd/admin_jwt_qovery_usage.go
new file mode 100644
index 00000000..85199cb3
--- /dev/null
+++ b/cmd/admin_jwt_qovery_usage.go
@@ -0,0 +1,28 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminJwtForQoveryUsageCmd = &cobra.Command{
+ Use: "jwt-qovery-usage",
+ Short: "Manage JWT for qovery usage ",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+ }
+)
+
+func init() {
+ adminCmd.AddCommand(adminJwtForQoveryUsageCmd)
+}
diff --git a/cmd/admin_k9s.go b/cmd/admin_k9s.go
index 4062979f..afa4438b 100644
--- a/cmd/admin_k9s.go
+++ b/cmd/admin_k9s.go
@@ -1,14 +1,19 @@
package cmd
import (
+ "os"
+ "os/exec"
+
"github.com/qovery/qovery-cli/pkg"
+
"github.com/qovery/qovery-cli/utils"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
- "os"
- "os/exec"
)
+var doNotConnectToBastion bool
+var readWriteMode bool
+
var k9sCmd = &cobra.Command{
Use: "k9s",
Short: "Launch k9s with a cluster ID",
@@ -19,6 +24,8 @@ var k9sCmd = &cobra.Command{
func init() {
adminCmd.AddCommand(k9sCmd)
+ k9sCmd.Flags().BoolVarP(&doNotConnectToBastion, "no-bastion", "n", false, "do not connect to the bastion")
+ k9sCmd.Flags().BoolVarP(&readWriteMode, "read-write", "w", false, "run k9s in read-write mode (default is read-only)")
}
func launchK9s(args []string) {
@@ -29,18 +36,34 @@ func launchK9s(args []string) {
return
}
- vars := pkg.GetVarsByClusterId(args[0])
- if len(vars) == 0 {
- return
+ var cleanup func()
+ if !doNotConnectToBastion {
+ cleanup = pkg.SetBastionConnection()
+ defer func() {
+ log.Info("Cleaning up SSH tunnel...")
+ cleanup()
+ }()
}
- for _, variable := range vars {
- os.Setenv(variable.Key, variable.Value)
+ clusterId := args[0]
+ kubeconfig := pkg.GetKubeconfigByClusterId(clusterId, false)
+ filePath := utils.WriteInFile(clusterId, "kubeconfig", []byte(kubeconfig))
+ if err := os.Setenv("KUBECONFIG", filePath); err != nil {
+ log.Fatal(err)
}
- utils.GenerateExportEnvVarsScript(vars, args[0])
log.Info("Launching k9s.")
- cmd := exec.Command("k9s")
+
+ var k9sArgs []string
+ // Run in read-only mode by default unless read-write flag is provided
+ if !readWriteMode {
+ k9sArgs = append(k9sArgs, "--readonly")
+ log.Info("Running k9s in read-only mode. Use --read-write flag to enable write operations.")
+ } else {
+ log.Info("Running k9s in read-write mode.")
+ }
+
+ cmd := exec.Command("k9s", k9sArgs...)
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
@@ -54,13 +77,9 @@ func launchK9s(args []string) {
}
func checkEnv() {
- if _, ok := os.LookupEnv("VAULT_ADDR"); !ok {
- log.Error("You must set vault address env variable (VAULT_ADDR).")
- os.Exit(1)
- }
-
- if _, ok := os.LookupEnv("VAULT_TOKEN"); !ok {
- log.Error("You must set vault token env variable (VAULT_TOKEN).")
+ if _, ok := os.LookupEnv("BASTION_ADDR"); !ok {
+ log.Error("You must set the bastion address (BASTION_ADDR).")
os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
}
}
diff --git a/cmd/admin_load_aws_credentials.go b/cmd/admin_load_aws_credentials.go
new file mode 100644
index 00000000..e5a363ea
--- /dev/null
+++ b/cmd/admin_load_aws_credentials.go
@@ -0,0 +1,32 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg"
+)
+
+var (
+ roleArn string
+ adminLoadAwsCredentialsCmd = &cobra.Command{
+ Use: "load-aws-credentials",
+ Short: "Load aws credentials from a role ARN",
+ Long: `This command is used to load aws credentials
+> Examples
+----------
+* Load AWS credentials from a role ARN arn:aws:iam::123456789012:role/qovery-user-role-xxx
+qovery admin load-aws-credentials --role-arn arn:aws:iam::123456789012:role/qovery-user-role-xxx
+
+`,
+ Run: func(cmd *cobra.Command, args []string) {
+ err := pkg.LoadAwsCredentials(roleArn)
+ utils.CheckError(err)
+ },
+ }
+)
+
+func init() {
+ adminLoadAwsCredentialsCmd.Flags().StringVarP(&roleArn, "role-arn", "r", "", "ARN of the AWS IAM role to assume")
+ adminCmd.AddCommand(adminLoadAwsCredentialsCmd)
+}
diff --git a/cmd/admin_load_credentials.go b/cmd/admin_load_credentials.go
new file mode 100644
index 00000000..ef6ab8e8
--- /dev/null
+++ b/cmd/admin_load_credentials.go
@@ -0,0 +1,32 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg"
+)
+
+var (
+ adminLoadCredentialsCmd = &cobra.Command{
+ Use: "load-credentials",
+ Short: "Load credentials for a given cluster ID",
+ Long: `This command is used to load credentials
+> Examples
+----------
+* Load credentials from a clusterID 12345678-1234-1234-1234-123456789012
+qovery admin load-credentials --cluster-id 12345678-1234-1234-1234-123456789012
+
+`,
+ Run: func(cmd *cobra.Command, args []string) {
+ err := pkg.LoadCredentials(clusterId, doNotConnectToBastion)
+ utils.CheckError(err)
+ },
+ }
+)
+
+func init() {
+ adminLoadCredentialsCmd.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "ID of the cluster to load credentials for")
+ adminLoadCredentialsCmd.Flags().BoolVarP(&doNotConnectToBastion, "no-bastion", "n", false, "do not connect to the bastion")
+ adminCmd.AddCommand(adminLoadCredentialsCmd)
+}
diff --git a/cmd/admin_lock.go b/cmd/admin_lock.go
deleted file mode 100644
index 4de73224..00000000
--- a/cmd/admin_lock.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package cmd
-
-import (
- "github.com/qovery/qovery-cli/pkg"
- log "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
-)
-
-var (
- adminLockByIdCmd = &cobra.Command{
- Use: "lock",
- Short: "Lock a cluster with its Id",
- Run: func(cmd *cobra.Command, args []string) {
- lockClusterById()
- },
- }
-)
-
-func init() {
- adminLockByIdCmd.Flags().StringVarP(&clusterId, "cluster", "c", "", "Cluster's id")
- adminLockByIdCmd.Flags().StringVarP(&lockReason, "reason", "r", "", "Lock reason")
- orgaErr = adminLockByIdCmd.MarkFlagRequired("cluster")
- orgaErr = adminLockByIdCmd.MarkFlagRequired("reason")
- adminCmd.AddCommand(adminLockByIdCmd)
-}
-
-func lockClusterById() {
- if orgaErr != nil {
- log.Error("Invalid cluster Id")
- } else {
- pkg.LockById(clusterId, lockReason)
- }
-}
diff --git a/cmd/admin_notify_users_cluster_failure.go b/cmd/admin_notify_users_cluster_failure.go
new file mode 100644
index 00000000..ed62395e
--- /dev/null
+++ b/cmd/admin_notify_users_cluster_failure.go
@@ -0,0 +1,40 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminNotifyUsersClusterFailureCmd = &cobra.Command{
+ Use: "notify-users-cluster-failure",
+ Short: "Notify users of a cluster failure",
+ Long: `Notify users by email of a cluster having FAILED status.
+- (Default) With --cluster-id, only admins of the cluster with the given id will be notified.
+- Without --cluster-id, admins of all clusters with FAILED status will be notified.
+`,
+ Run: func(cmd *cobra.Command, args []string) {
+ notifyUsersClusterFailure()
+ },
+ }
+)
+
+func init() {
+ adminNotifyUsersClusterFailureCmd.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "Cluster ID")
+ adminCmd.AddCommand(adminNotifyUsersClusterFailureCmd)
+}
+
+func notifyUsersClusterFailure() {
+ utils.GetAdminUrl()
+
+ err := pkg.NotifyUsersClusterFailure(&clusterId)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
diff --git a/cmd/admin_organization_deployment_restriction.go b/cmd/admin_organization_deployment_restriction.go
new file mode 100644
index 00000000..643fdb0e
--- /dev/null
+++ b/cmd/admin_organization_deployment_restriction.go
@@ -0,0 +1,199 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ adminOrganizationDeploymentRestrictionCmd = &cobra.Command{
+ Use: "deployment-restriction",
+ Short: "Block or unblock organization deployments",
+ Long: `Manage organization deployment restrictions.
+This command allows you to block or unblock deployments for a specific organization.
+
+Examples:
+ qovery admin deployment-restriction --organization-id 12345678-1234-1234-1234-123456789abc --action block --message "Payment overdue"
+ qovery admin deployment-restriction --organization-id 12345678-1234-1234-1234-123456789abc --action unblock`,
+ Run: func(cmd *cobra.Command, args []string) {
+ manageOrganizationDeploymentRestriction()
+ },
+ }
+)
+
+func init() {
+ adminOrganizationDeploymentRestrictionCmd.Flags().StringVarP(&organizationId, "organization-id", "o", "", "Organization ID")
+ adminOrganizationDeploymentRestrictionCmd.Flags().StringVarP(&deploymentAction, "action", "a", "", "Action to perform: block or unblock")
+ adminOrganizationDeploymentRestrictionCmd.Flags().StringVarP(&restrictionMessage, "message", "m", "", "Message explaining the reason for blocking (required when action is 'block')")
+
+ _ = adminOrganizationDeploymentRestrictionCmd.MarkFlagRequired("organization-id")
+ _ = adminOrganizationDeploymentRestrictionCmd.MarkFlagRequired("action")
+
+ adminCmd.AddCommand(adminOrganizationDeploymentRestrictionCmd)
+}
+
+var deploymentAction string
+var restrictionMessage string
+
+func manageOrganizationDeploymentRestriction() {
+ // Validate action
+ if deploymentAction != "block" && deploymentAction != "unblock" {
+ utils.PrintlnError(fmt.Errorf("action must be either 'block' or 'unblock', got: %s", deploymentAction))
+ os.Exit(1)
+ }
+
+ // Validate organization ID format (basic UUID check)
+ if organizationId == "" {
+ utils.PrintlnError(fmt.Errorf("organization ID is required"))
+ os.Exit(1)
+ }
+
+ // Basic UUID format validation
+ if len(organizationId) != 36 ||
+ !strings.Contains(organizationId, "-") ||
+ strings.Count(organizationId, "-") != 4 {
+ utils.PrintlnError(fmt.Errorf("organization ID must be a valid UUID format (e.g., 12345678-1234-1234-1234-123456789abc)"))
+ os.Exit(1)
+ }
+
+ // Validate message is provided when blocking
+ if deploymentAction == "block" {
+ if restrictionMessage == "" {
+ utils.PrintlnError(fmt.Errorf("message is required when action is 'block'. Use --message flag to provide a reason"))
+ os.Exit(1)
+ }
+
+ // Validate message length and content
+ if len(strings.TrimSpace(restrictionMessage)) < 3 {
+ utils.PrintlnError(fmt.Errorf("message must be at least 3 characters long"))
+ os.Exit(1)
+ }
+
+ if len(restrictionMessage) > 500 {
+ utils.PrintlnError(fmt.Errorf("message must be less than 500 characters"))
+ os.Exit(1)
+ }
+ }
+
+ // Validate that message is not provided for unblock action
+ if deploymentAction == "unblock" && restrictionMessage != "" {
+ utils.PrintlnError(fmt.Errorf("message should not be provided when action is 'unblock'"))
+ os.Exit(1)
+ }
+
+ // Show confirmation prompt
+ utils.PrintlnInfo(fmt.Sprintf("You are about to %s deployments for organization: %s", deploymentAction, organizationId))
+ if deploymentAction == "block" {
+ utils.PrintlnInfo(fmt.Sprintf("Reason: %s", restrictionMessage))
+ }
+ utils.PrintlnInfo("This action will affect ALL deployments for this organization.")
+
+ // Ask for confirmation
+ if !utils.Validate("deployment restriction") {
+ utils.PrintlnInfo("Operation cancelled.")
+ return
+ }
+
+ // Get access token
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ // Prepare request payload
+ payload := struct {
+ Action string `json:"action"`
+ Message string `json:"message,omitempty"`
+ }{
+ Action: deploymentAction,
+ Message: restrictionMessage,
+ }
+
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to marshal payload: %w", err))
+ os.Exit(1)
+ }
+
+ // Create HTTP request
+ url := fmt.Sprintf("%s/organization/%s/deploymentRestriction", utils.GetAdminUrl(), organizationId)
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payloadBytes))
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to create request: %w", err))
+ os.Exit(1)
+ }
+
+ // Set headers
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Execute request
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to execute request: %w", err))
+ os.Exit(1)
+ }
+ defer func() {
+ if err := res.Body.Close(); err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to close response body: %w", err))
+ }
+ }()
+
+ // Read response body
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to read response body: %w", err))
+ os.Exit(1)
+ }
+
+ // Handle response based on status code
+ switch res.StatusCode {
+ case http.StatusOK:
+ // Try to parse response for more details
+ var response struct {
+ Message string `json:"message"`
+ Status string `json:"status"`
+ }
+
+ if err := json.Unmarshal(body, &response); err == nil && response.Message != "" {
+ utils.PrintlnInfo(fmt.Sprintf("â
%s", response.Message))
+ } else {
+ // Fallback to generic success message
+ actionText := "blocked"
+ if deploymentAction == "unblock" {
+ actionText = "unblocked"
+ }
+ utils.PrintlnInfo(fmt.Sprintf("â
Organization %s has been %s successfully", organizationId, actionText))
+ }
+
+ case http.StatusNotFound:
+ utils.PrintlnError(fmt.Errorf("â Organization not found: %s", organizationId))
+ os.Exit(1)
+
+ case http.StatusUnauthorized:
+ utils.PrintlnError(fmt.Errorf("â Unauthorized: You don't have permission to perform this action"))
+ os.Exit(1)
+
+ case http.StatusForbidden:
+ utils.PrintlnError(fmt.Errorf("â Forbidden: You don't have permission to perform this action"))
+ os.Exit(1)
+
+ case http.StatusBadRequest:
+ utils.PrintlnError(fmt.Errorf("â Bad request: %s", string(body)))
+ os.Exit(1)
+
+ default:
+ utils.PrintlnError(fmt.Errorf("â Request failed with status %s: %s", res.Status, string(body)))
+ os.Exit(1)
+ }
+}
diff --git a/cmd/admin_organization_transfer_ownership.go b/cmd/admin_organization_transfer_ownership.go
new file mode 100644
index 00000000..b05705e1
--- /dev/null
+++ b/cmd/admin_organization_transfer_ownership.go
@@ -0,0 +1,178 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ newOwnerUserId string
+ newOwnerEmail string
+ authProvider string
+ adminTransferOrganizationOwnership = &cobra.Command{
+ Use: "transfer-ownership",
+ Short: "Transfer organization ownership to another user",
+ Long: `Transfer organization ownership to another user by providing the organization ID and either the new owner's user ID or email.
+
+Example:
+ qovery admin transfer-ownership --organization-id "xxx-xxx-xxx" --user-id "auth0|xxx"
+ qovery admin transfer-ownership --organization-id "xxx-xxx-xxx" --email "user@example.com"
+ qovery admin transfer-ownership --organization-id "xxx-xxx-xxx" --email "user@example.com" --provider "github"
+`,
+ Run: func(cmd *cobra.Command, args []string) {
+ transferOrganizationOwnership()
+ },
+ }
+)
+
+func init() {
+ adminTransferOrganizationOwnership.Flags().StringVarP(&organizationId, "organization-id", "o", "", "Organization ID (required)")
+ adminTransferOrganizationOwnership.Flags().StringVarP(&newOwnerUserId, "user-id", "u", "", "New owner user ID")
+ adminTransferOrganizationOwnership.Flags().StringVarP(&newOwnerEmail, "email", "e", "", "New owner email address")
+ adminTransferOrganizationOwnership.Flags().StringVarP(&authProvider, "provider", "p", "", "Auth provider (auth0, github, gitlab, google, etc.) - required if multiple users have the same email")
+
+ _ = adminTransferOrganizationOwnership.MarkFlagRequired("organization-id")
+
+ adminCmd.AddCommand(adminTransferOrganizationOwnership)
+}
+
+func transferOrganizationOwnership() {
+ // Validate required fields
+ if organizationId == "" {
+ utils.PrintlnError(fmt.Errorf("organization ID is required"))
+ os.Exit(1)
+ }
+
+ // Ensure either user ID or email is provided
+ if newOwnerUserId == "" && newOwnerEmail == "" {
+ utils.PrintlnError(fmt.Errorf("either --user-id or --email must be provided"))
+ os.Exit(1)
+ }
+
+ if newOwnerUserId != "" && newOwnerEmail != "" {
+ utils.PrintlnError(fmt.Errorf("only one of --user-id or --email should be provided, not both"))
+ os.Exit(1)
+ }
+
+ // Get access token
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ // Get Qovery client
+ client := utils.GetQoveryClient(tokenType, token)
+
+ // If email is provided, find the user ID from organization members
+ targetUserId := newOwnerUserId
+ if newOwnerEmail != "" {
+ utils.Println(fmt.Sprintf("đ Looking up user with email: %s", newOwnerEmail))
+
+ members, res, err := client.MembersAPI.GetOrganizationMembers(context.Background(), organizationId).Execute()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to list organization members: %w", err))
+ if res != nil {
+ utils.PrintlnError(fmt.Errorf("response status: %s", res.Status))
+ }
+ os.Exit(1)
+ }
+
+ // Find all members with matching email
+ var matchingMembers []qovery.Member
+ for _, member := range members.GetResults() {
+ if member.Email == newOwnerEmail {
+ matchingMembers = append(matchingMembers, member)
+ }
+ }
+
+ if len(matchingMembers) == 0 {
+ utils.PrintlnError(fmt.Errorf("no member found with email '%s' in organization %s", newOwnerEmail, organizationId))
+ os.Exit(1)
+ }
+
+ // If multiple members found with the same email, check if provider is specified
+ if len(matchingMembers) > 1 {
+ if authProvider == "" {
+ // Extract providers from user IDs
+ var providers []string
+ for _, member := range matchingMembers {
+ // User ID format: "provider|id" (e.g., "auth0|123", "github|456")
+ parts := strings.Split(member.Id, "|")
+ if len(parts) >= 2 {
+ providers = append(providers, parts[0])
+ }
+ }
+
+ utils.PrintlnError(fmt.Errorf("multiple users found with email '%s'. Please specify --provider flag", newOwnerEmail))
+ utils.PrintlnError(fmt.Errorf("available providers: %v", providers))
+ os.Exit(1)
+ }
+
+ // Filter by provider
+ var foundMember *qovery.Member
+ for _, member := range matchingMembers {
+ // User ID format: "provider|id"
+ parts := strings.Split(member.Id, "|")
+ if len(parts) >= 2 && strings.EqualFold(parts[0], authProvider) {
+ foundMember = &member
+ break
+ }
+ }
+
+ if foundMember == nil {
+ utils.PrintlnError(fmt.Errorf("no member found with email '%s' and provider '%s'", newOwnerEmail, authProvider))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ targetUserId = foundMember.Id
+ utils.Println(fmt.Sprintf("â
Found user: %s (Provider: %s, ID: %s)", foundMember.Email, authProvider, targetUserId))
+ } else {
+ // Only one member found with this email
+ targetUserId = matchingMembers[0].Id
+ // Extract provider for display
+ parts := strings.Split(targetUserId, "|")
+ provider := "unknown"
+ if len(parts) >= 2 {
+ provider = parts[0]
+ }
+ utils.Println(fmt.Sprintf("â
Found user: %s (Provider: %s, ID: %s)", matchingMembers[0].Email, provider, targetUserId))
+ }
+ }
+
+ // Prepare transfer ownership request
+ transferRequest := *qovery.NewTransferOwnershipRequest(targetUserId)
+
+ // Execute transfer
+ utils.Println(fmt.Sprintf("đ Transferring ownership to user %s...", targetUserId))
+ res, err := client.MembersAPI.PostOrganizationTransferOwnership(context.Background(), organizationId).
+ TransferOwnershipRequest(transferRequest).
+ Execute()
+
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to transfer ownership: %w", err))
+ if res != nil {
+ utils.PrintlnError(fmt.Errorf("response status: %s", res.Status))
+ }
+ os.Exit(1)
+ }
+
+ if res != nil && res.StatusCode >= 400 {
+ utils.PrintlnError(fmt.Errorf("failed to transfer ownership with status: %s", res.Status))
+ os.Exit(1)
+ }
+
+ if newOwnerEmail != "" {
+ utils.Println(fmt.Sprintf("â
Successfully transferred ownership of organization %s to %s", organizationId, newOwnerEmail))
+ } else {
+ utils.Println(fmt.Sprintf("â
Successfully transferred ownership of organization %s to user %s", organizationId, targetUserId))
+ }
+}
diff --git a/cmd/admin_organization_update_billing_external_id.go b/cmd/admin_organization_update_billing_external_id.go
new file mode 100644
index 00000000..ea871560
--- /dev/null
+++ b/cmd/admin_organization_update_billing_external_id.go
@@ -0,0 +1,91 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ billingExternalId string
+ adminOrganizationUpdateBillingExternalId = &cobra.Command{
+ Use: "update-billing-external-id",
+ Short: "Update the billing external ID (Chargebee subscription ID) of an organization",
+ Long: `Update the billing external ID of an organization. The billing external ID is the Chargebee subscription ID.
+
+Example:
+ qovery admin update-billing-external-id --organization-id "xxx-xxx-xxx" --billing-external-id "AzyXZ8T0EI4jB4AZf"
+`,
+ Run: func(cmd *cobra.Command, args []string) {
+ updateOrganizationBillingExternalId()
+ },
+ }
+)
+
+func init() {
+ adminOrganizationUpdateBillingExternalId.Flags().StringVarP(&organizationId, "organization-id", "o", "", "Organization ID (required)")
+ adminOrganizationUpdateBillingExternalId.Flags().StringVarP(&billingExternalId, "billing-external-id", "b", "", "Chargebee subscription ID (required)")
+
+ _ = adminOrganizationUpdateBillingExternalId.MarkFlagRequired("organization-id")
+ _ = adminOrganizationUpdateBillingExternalId.MarkFlagRequired("billing-external-id")
+
+ adminCmd.AddCommand(adminOrganizationUpdateBillingExternalId)
+}
+
+func updateOrganizationBillingExternalId() {
+ if organizationId == "" {
+ utils.PrintlnError(fmt.Errorf("organization ID is required"))
+ os.Exit(1)
+ }
+ if billingExternalId == "" {
+ utils.PrintlnError(fmt.Errorf("billing external ID is required"))
+ os.Exit(1)
+ }
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ type requestBody struct {
+ BillingExternalId string `json:"billing_external_id"`
+ }
+
+ bodyBytes, err := json.Marshal(requestBody{BillingExternalId: billingExternalId})
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to marshal request body: %w", err))
+ os.Exit(1)
+ }
+
+ url := utils.GetAdminUrl() + "/organization/" + organizationId + "/billingExternalId"
+ req, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to create request: %w", err))
+ os.Exit(1)
+ }
+
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to execute request: %w", err))
+ os.Exit(1)
+ }
+ defer func() { _ = res.Body.Close() }()
+
+ if res.StatusCode >= 400 {
+ body, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(fmt.Errorf("request failed (status=%d): %s", res.StatusCode, string(body)))
+ os.Exit(1)
+ }
+
+ utils.Println(fmt.Sprintf("â
Successfully updated billing external ID for organization %s", organizationId))
+}
diff --git a/cmd/admin_publish_environment_deployment_rules.go b/cmd/admin_publish_environment_deployment_rules.go
new file mode 100644
index 00000000..33c425f0
--- /dev/null
+++ b/cmd/admin_publish_environment_deployment_rules.go
@@ -0,0 +1,30 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/pkg"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var (
+ adminPublishEnvironmentDeploymentRulesCmd = &cobra.Command{
+ Use: "publish-environment-deployment-rules",
+ Short: "Republish environment deployment rules to scheduler",
+ Run: func(cmd *cobra.Command, args []string) {
+ publishEnvironmentDeploymentRules()
+ },
+ }
+)
+
+func init() {
+ adminCmd.AddCommand(adminPublishEnvironmentDeploymentRulesCmd)
+}
+
+func publishEnvironmentDeploymentRules() {
+ err := pkg.PublishEnvironmentDeploymentRules()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/admin_s3_archive_dowload.go b/cmd/admin_s3_archive_dowload.go
new file mode 100644
index 00000000..ef652da5
--- /dev/null
+++ b/cmd/admin_s3_archive_dowload.go
@@ -0,0 +1,32 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/pkg"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var (
+ downloadS3ArchiveCmd = &cobra.Command{
+ Use: "download-s3-archive",
+ Short: "Download S3 archive by execution id",
+ Run: func(cmd *cobra.Command, args []string) {
+ downloadS3Archive()
+ },
+ }
+)
+
+func init() {
+ downloadS3ArchiveCmd.Flags().StringVarP(&execId, "exec-id", "e", "", "Execution id")
+ downloadS3ArchiveCmd.Flags().StringVarP(&directory, "directory", "d", ".", "Directory where the archive will be downloaded")
+ orgaErr = downloadS3ArchiveCmd.MarkFlagRequired("exec-id")
+ adminCmd.AddCommand(downloadS3ArchiveCmd)
+}
+
+func downloadS3Archive() {
+ if orgaErr != nil {
+ log.Error("Invalid organization Id")
+ } else {
+ pkg.DownloadS3Archive(execId, directory)
+ }
+}
diff --git a/cmd/admin_unlock.go b/cmd/admin_unlock.go
deleted file mode 100644
index 0f7ec1bc..00000000
--- a/cmd/admin_unlock.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package cmd
-
-import (
- "github.com/qovery/qovery-cli/pkg"
- log "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
-)
-
-var (
- adminUnlockByIdCmd = &cobra.Command{
- Use: "unlock",
- Short: "Unlock a cluster with its Id",
- Run: func(cmd *cobra.Command, args []string) {
- unlockClusterById()
- },
- }
-)
-
-func init() {
- adminUnlockByIdCmd.Flags().StringVarP(&clusterId, "cluster", "c", "", "Cluster's id")
- orgaErr = adminUnlockByIdCmd.MarkFlagRequired("cluster")
- adminCmd.AddCommand(adminUnlockByIdCmd)
-}
-
-func unlockClusterById() {
- if orgaErr != nil {
- log.Error("Invalid cluster Id")
- } else {
- pkg.UnockById(clusterId)
- }
-}
diff --git a/cmd/admin_update_all_kube.go b/cmd/admin_update_all_kube.go
index fe467562..0dcb2ba2 100644
--- a/cmd/admin_update_all_kube.go
+++ b/cmd/admin_update_all_kube.go
@@ -11,7 +11,7 @@ var (
parallelRun int
providerErr error
adminUpdateAllCmd = &cobra.Command{
- Use: "update_bulk",
+ Use: "update-bulk",
Short: "Update an amount of clusters to a specific version based on cloud provider kind.",
Run: func(cmd *cobra.Command, args []string) {
updateAllClusters()
@@ -22,7 +22,7 @@ var (
func init() {
adminUpdateAllCmd.Flags().BoolVarP(&dryRun, "disable-dry-run", "y", false, "Disable dry run mode")
adminUpdateAllCmd.Flags().StringVarP(&version, "version", "v", "", "Targeted version")
- adminUpdateAllCmd.Flags().StringVarP(&providerKind, "provider-kind", "k", "", "Provider to upgrade. Can be : AWS, DO or SCW")
+ adminUpdateAllCmd.Flags().StringVarP(&providerKind, "provider-kind", "k", "", "Provider to upgrade. Can be : AWS, AZURE, GCP or SCW")
adminUpdateAllCmd.Flags().IntVarP(¶llelRun, "parallel-run", "p", 1, "Number of parallel upgrades. Max is 20.")
versionErr = adminUpdateAllCmd.MarkFlagRequired("version")
providerErr = adminUpdateAllCmd.MarkFlagRequired("provider-kind")
diff --git a/cmd/api.go b/cmd/api.go
new file mode 100644
index 00000000..ef306e66
--- /dev/null
+++ b/cmd/api.go
@@ -0,0 +1,320 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var apiMethod string
+var apiInput string
+var apiFields []string
+var apiHeaders []string
+var apiInclude bool
+
+var apiCmd = &cobra.Command{
+ Use: "api ",
+ Short: "Make an authenticated request to the Qovery API",
+ Long: `Make an authenticated HTTP request to the Qovery API.
+
+EXAMPLES
+
+ # List organizations
+ $ qovery api organization
+
+ # Get a specific organization
+ $ qovery api organization/
+
+ # List projects in current organization (from context)
+ $ qovery api organization/{organizationId}/project
+
+ # Get current environment's services (fully from context)
+ $ qovery api organization/{organizationId}/project/{projectId}/environment/{environmentId}/service
+
+ # Create an organization using --field
+ $ qovery api organization --field name=my-org --field plan=FREE
+
+ # Pipe body from stdin
+ $ echo '{"name":"my-org","plan":"FREE"}' | qovery api organization --input -
+
+ # Send a JSON file as body
+ $ qovery api organization//project --input - < body.json
+
+ # Delete a resource
+ $ qovery api organization/ --method DELETE
+
+ # Show response headers
+ $ qovery api organization --include
+
+ # Add a custom header
+ $ qovery api organization -H "X-Request-Id: abc123"
+
+ # Use a staging environment
+ $ QOVERY_API_URL=https://staging.api.qovery.com qovery api organization`,
+ Args: cobra.ExactArgs(1),
+ Run: runAPI,
+}
+
+func init() {
+ rootCmd.AddCommand(apiCmd)
+ apiCmd.Flags().StringVarP(&apiMethod, "method", "X", "", "HTTP method (GET, POST, PUT, PATCH, DELETE)")
+ apiCmd.Flags().StringVar(&apiInput, "input", "", "Body: '-' for stdin (pipe JSON to command)")
+ apiCmd.Flags().StringArrayVarP(&apiFields, "field", "f", []string{}, "Add a key=value pair to the JSON body (repeatable, smart type coercion)")
+ apiCmd.Flags().StringArrayVarP(&apiHeaders, "header", "H", []string{}, "Additional request headers in 'Key: Value' format (repeatable)")
+ apiCmd.Flags().BoolVarP(&apiInclude, "include", "i", false, "Print HTTP response status and headers before body")
+}
+
+// isValidHTTPHeaderName reports whether name is a valid HTTP token per RFC 7230.
+func isValidHTTPHeaderName(name string) bool {
+ if name == "" {
+ return false
+ }
+ for i := 0; i < len(name); i++ {
+ ch := name[i]
+ if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
+ continue
+ }
+ switch ch {
+ case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~':
+ continue
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// validateAPIArgs validates all arguments and flag values before any I/O.
+// It returns an error describing the first problem found.
+func validateAPIArgs(endpoint, method, input string, fields, headers []string) error {
+ if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
+ return errors.New("endpoint must be a path (e.g. /organization), not a full URL")
+ }
+ if input != "" && input != "-" {
+ return errors.New(`--input only accepts '-' (stdin); to send a file: qovery api --input - < file.json`)
+ }
+ if len(fields) > 0 && input != "" {
+ return errors.New("--field and --input are mutually exclusive")
+ }
+ allowed := map[string]bool{"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true}
+ if method != "" && !allowed[method] {
+ return fmt.Errorf("invalid HTTP method %q: must be one of GET, POST, PUT, PATCH, DELETE", method)
+ }
+ for _, h := range headers {
+ idx := strings.Index(h, ":")
+ if idx <= 0 {
+ return fmt.Errorf("invalid header %q: must be in 'Key: Value' format", h)
+ }
+ name := h[:idx]
+ if !isValidHTTPHeaderName(name) {
+ return fmt.Errorf("invalid header name %q: must be a non-empty HTTP token", name)
+ }
+ }
+ seen := make(map[string]bool)
+ for _, f := range fields {
+ idx := strings.Index(f, "=")
+ if idx == -1 {
+ return fmt.Errorf("invalid field %q: must be in 'key=value' format", f)
+ }
+ key := f[:idx]
+ if key == "" {
+ return fmt.Errorf("invalid field %q: key must not be empty", f)
+ }
+ if seen[key] {
+ return fmt.Errorf("duplicate field key %q: each key may only appear once", key)
+ }
+ seen[key] = true
+ }
+ return nil
+}
+
+// writeResponse writes the response status line, headers (if include), and body
+// to a single stream: stdout for 2xx responses, stderr for non-2xx.
+// Returns true on success (2xx), false on error response.
+func writeResponse(resp *http.Response, include bool, stdout, stderr io.Writer) (bool, error) {
+ is2xx := resp.StatusCode >= 200 && resp.StatusCode < 300
+ out := stdout
+ if !is2xx {
+ out = stderr
+ }
+
+ if include {
+ _, _ = fmt.Fprintf(out, "HTTP/%d.%d %s\n", resp.ProtoMajor, resp.ProtoMinor, resp.Status)
+ headerKeys := make([]string, 0, len(resp.Header))
+ for k := range resp.Header {
+ headerKeys = append(headerKeys, k)
+ }
+ sort.Strings(headerKeys)
+ for _, k := range headerKeys {
+ for _, v := range resp.Header[k] {
+ _, _ = fmt.Fprintf(out, "%s: %s\n", k, v)
+ }
+ }
+ _, _ = fmt.Fprintln(out)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return false, err
+ }
+ _, _ = out.Write(body)
+ return is2xx, nil
+}
+
+// substitutePathPlaceholders replaces {organizationId}, {projectId}, {environmentId}, {serviceId}
+// in the path with values from the current Qovery context (best-effort â errors silently ignored).
+// Empty context values leave the literal placeholder unchanged.
+func substitutePathPlaceholders(path string) string {
+ ctx, _ := utils.GetCurrentContext()
+ pairs := []struct {
+ placeholder string
+ value string
+ }{
+ {"{organizationId}", string(ctx.OrganizationId)},
+ {"{projectId}", string(ctx.ProjectId)},
+ {"{environmentId}", string(ctx.EnvironmentId)},
+ {"{serviceId}", string(ctx.ServiceId)},
+ }
+ for _, p := range pairs {
+ if p.value != "" {
+ path = strings.ReplaceAll(path, p.placeholder, p.value)
+ }
+ }
+ return path
+}
+
+// coerceFieldValue applies smart type coercion for --field values.
+// Order: bool â int64 â float64 â string.
+func coerceFieldValue(v string) any {
+ if v == "true" {
+ return true
+ }
+ if v == "false" {
+ return false
+ }
+ if i, err := strconv.ParseInt(v, 10, 64); err == nil {
+ return i
+ }
+ if f, err := strconv.ParseFloat(v, 64); err == nil {
+ return f
+ }
+ return v
+}
+
+func runAPI(cmd *cobra.Command, args []string) {
+ endpoint := args[0]
+
+ if err := validateAPIArgs(endpoint, apiMethod, apiInput, apiFields, apiHeaders); err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ // Parse headers (format already validated)
+ parsedHeaders := make(map[string]string)
+ for _, h := range apiHeaders {
+ idx := strings.Index(h, ":")
+ parsedHeaders[h[:idx]] = strings.TrimPrefix(h[idx+1:], " ")
+ }
+
+ // Parse fields (format already validated)
+ parsedFields := make(map[string]string)
+ for _, f := range apiFields {
+ idx := strings.Index(f, "=")
+ parsedFields[f[:idx]] = f[idx+1:]
+ }
+
+ // Determine effective HTTP method
+ method := apiMethod
+ if method == "" {
+ if apiInput != "" || len(apiFields) > 0 {
+ method = "POST"
+ } else {
+ method = "GET"
+ }
+ }
+
+ // Build the full URL
+ path := strings.TrimLeft(endpoint, "/")
+ path = substitutePathPlaceholders(path)
+ fullURL := utils.GetAPIBaseURL() + "/" + path
+
+ // Build request body
+ var body io.Reader
+ hasBody := apiInput != "" || len(apiFields) > 0
+
+ switch {
+ case apiInput == "-":
+ body = os.Stdin
+ case len(apiFields) > 0:
+ fieldMap := make(map[string]any, len(parsedFields))
+ for k, v := range parsedFields {
+ fieldMap[k] = coerceFieldValue(v)
+ }
+ jsonBytes, err := json.Marshal(fieldMap)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ body = bytes.NewReader(jsonBytes)
+ }
+
+ // Create HTTP request
+ req, err := http.NewRequest(method, fullURL, body)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ // Get auth token. Creating the very first organization (`qovery api organization
+ // --method POST ...`, documented above as a first-class example) is the one
+ // legitimate case where the caller is expected to have zero organizations yet,
+ // so it skips the usual "you don't have any organization" guard.
+ isOrgCreation := path == "organization" && method == "POST"
+ tokenType, token, err := utils.GetAccessToken(isOrgCreation)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ // Set Authorization header
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+
+ // Set default Content-Type when body is expected (flag presence check, not body-nil check)
+ if hasBody {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ // Apply user headers (always wins â applied after defaults)
+ for k, v := range parsedHeaders {
+ req.Header.Set(k, v)
+ }
+
+ // Execute request with 60s timeout
+ client := &http.Client{Timeout: 60 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ ok, err := writeResponse(resp, apiInclude, os.Stdout, os.Stderr)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ if !ok {
+ os.Exit(1)
+ }
+}
diff --git a/cmd/api_spec.go b/cmd/api_spec.go
new file mode 100644
index 00000000..d9de5961
--- /dev/null
+++ b/cmd/api_spec.go
@@ -0,0 +1,90 @@
+package cmd
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+// openAPISpecURL points at the canonical source of truth for the Qovery API spec.
+// There is no dedicated docs site serving the raw file (api-doc.qovery.com now
+// redirects to the rendered docs), so the GitHub repo itself is the only stable
+// place to fetch it from.
+const openAPISpecURL = "https://raw.githubusercontent.com/Qovery/qovery-openapi-spec/main/openapi.yaml"
+
+var apiSpecOutput string
+
+var apiSpecCmd = &cobra.Command{
+ Use: "spec",
+ Short: "Print the Qovery API's OpenAPI specification",
+ Long: `Fetch and print the Qovery API's OpenAPI specification (YAML), sourced from
+https://github.com/Qovery/qovery-openapi-spec.
+
+Use it to discover valid endpoints, methods, and request/response shapes before
+calling 'qovery api ' â instead of guessing or relying on a copy that
+may be out of date. This command does not require authentication.
+
+EXAMPLES
+
+ # Print the spec to stdout
+ $ qovery api spec
+
+ # Save it to a file
+ $ qovery api spec -o openapi.yaml
+
+ # Look up one path with a YAML query tool
+ $ qovery api spec | yq '.paths["/organization"]'`,
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Get(openAPISpecURL)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("could not reach %s: %w", openAPISpecURL, err))
+ os.Exit(1)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ utils.PrintlnError(fmt.Errorf("failed to fetch OpenAPI spec: server returned %s", resp.Status))
+ os.Exit(1)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ if apiSpecOutput != "" {
+ if err := os.WriteFile(apiSpecOutput, body, 0644); err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ // Status message goes to stderr, not stdout, so stdout stays reserved
+ // for the spec itself (the whole point of -o is a clean stdout to script against).
+ fmt.Fprintln(os.Stderr, "OpenAPI spec written to "+apiSpecOutput)
+ return
+ }
+
+ if n, err := os.Stdout.Write(body); err != nil || n != len(body) {
+ if err == nil {
+ err = fmt.Errorf("short write: wrote %d of %d bytes", n, len(body))
+ }
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ },
+}
+
+func init() {
+ apiCmd.AddCommand(apiSpecCmd)
+ apiSpecCmd.Flags().StringVarP(&apiSpecOutput, "output", "o", "", "Write the spec to a file instead of stdout")
+}
diff --git a/cmd/api_test.go b/cmd/api_test.go
new file mode 100644
index 00000000..feef4563
--- /dev/null
+++ b/cmd/api_test.go
@@ -0,0 +1,504 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+// captureOutput temporarily replaces os.Stdout and os.Stderr and returns the
+// data written to each after the function returns.
+func captureOutput(fn func()) (stdout string, stderr string) {
+ oldOut := os.Stdout
+ oldErr := os.Stderr
+ defer func() {
+ os.Stdout = oldOut
+ os.Stderr = oldErr
+ }()
+
+ rOut, wOut, _ := os.Pipe()
+ rErr, wErr, _ := os.Pipe()
+ os.Stdout = wOut
+ os.Stderr = wErr
+
+ fn()
+
+ _ = wOut.Close()
+ _ = wErr.Close()
+
+ outBuf, _ := io.ReadAll(rOut)
+ errBuf, _ := io.ReadAll(rErr)
+ return string(outBuf), string(errBuf)
+}
+
+// writeContextFile creates a minimal ~/.qovery/context.json in a temp HOME dir
+// and sets HOME to that dir.
+func writeContextFile(t *testing.T, orgID, projectID, envID, serviceID string) {
+ t.Helper()
+ tmpHome := t.TempDir()
+ qoveryDir := filepath.Join(tmpHome, ".qovery")
+ if err := os.MkdirAll(qoveryDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ contextData := fmt.Sprintf(`{
+ "access_token": "fake",
+ "access_token_expiration": "2099-01-01T00:00:00Z",
+ "refresh_token": "fake",
+ "organization_id": %q,
+ "project_id": %q,
+ "environment_id": %q,
+ "service_id": %q
+ }`, orgID, projectID, envID, serviceID)
+ if err := os.WriteFile(filepath.Join(qoveryDir, "context.json"), []byte(contextData), 0600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("HOME", tmpHome)
+}
+
+// --- Scenario 1: GET 200 â body written to stdout ---
+func TestAPIGet200(t *testing.T) {
+ t.Setenv("QOVERY_CLI_ACCESS_TOKEN", "fake-token")
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ expected := `{"results":[]}`
+ httpmock.RegisterResponder("GET", "https://api.qovery.com/organization",
+ httpmock.NewStringResponder(200, expected))
+
+ // Reset flag state
+ apiMethod = ""
+ apiInput = ""
+ apiFields = []string{}
+ apiHeaders = []string{}
+ apiInclude = false
+
+ stdout, _ := captureOutput(func() {
+ runAPI(apiCmd, []string{"organization"})
+ })
+
+ assert.Equal(t, expected, stdout)
+}
+
+// --- Scenario 2: POST with stdin body ---
+func TestAPIPostStdin(t *testing.T) {
+ t.Setenv("QOVERY_CLI_ACCESS_TOKEN", "fake-token")
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ requestBody := `{"name":"my-org","plan":"FREE"}`
+ var capturedBody string
+ httpmock.RegisterResponder("POST", "https://api.qovery.com/organization",
+ func(req *http.Request) (*http.Response, error) {
+ b, _ := io.ReadAll(req.Body)
+ capturedBody = string(b)
+ return httpmock.NewStringResponse(200, `{"id":"123"}`), nil
+ })
+
+ // Replace stdin
+ r, w, _ := os.Pipe()
+ _, _ = w.WriteString(requestBody)
+ _ = w.Close()
+ oldStdin := os.Stdin
+ os.Stdin = r
+ defer func() { os.Stdin = oldStdin }()
+
+ apiMethod = "POST"
+ apiInput = "-"
+ apiFields = []string{}
+ apiHeaders = []string{}
+ apiInclude = false
+
+ captureOutput(func() {
+ runAPI(apiCmd, []string{"organization"})
+ })
+
+ assert.Equal(t, requestBody, capturedBody)
+}
+
+// --- Scenario 3: --input with file path is rejected ---
+func TestAPIInputFilePathRejected(t *testing.T) {
+ err := validateAPIArgs("organization", "", "body.json", nil, nil)
+ assert.ErrorContains(t, err, "--input only accepts")
+}
+
+// --- Scenario 4: DELETE method ---
+func TestAPIDelete(t *testing.T) {
+ t.Setenv("QOVERY_CLI_ACCESS_TOKEN", "fake-token")
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ var capturedMethod string
+ httpmock.RegisterResponder("DELETE", "https://api.qovery.com/organization/abc",
+ func(req *http.Request) (*http.Response, error) {
+ capturedMethod = req.Method
+ return httpmock.NewStringResponse(200, ``), nil
+ })
+
+ apiMethod = "DELETE"
+ apiInput = ""
+ apiFields = []string{}
+ apiHeaders = []string{}
+ apiInclude = false
+
+ stdout, _ := captureOutput(func() {
+ runAPI(apiCmd, []string{"organization/abc"})
+ })
+
+ assert.Equal(t, "DELETE", capturedMethod)
+ assert.Equal(t, "", stdout) // DELETE 200 with empty body â nothing on stdout
+}
+
+// --- Scenario 5: Invalid method (pure unit test via validateAPIArgs) ---
+func TestAPIInvalidMethod(t *testing.T) {
+ assert.ErrorContains(t, validateAPIArgs("organization", "BREW", "", nil, nil), "invalid HTTP method")
+}
+
+// --- Scenario 6: Non-2xx â body to stderr, nothing to stdout ---
+func TestAPINon2xx(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ errorBody := `{"status":404,"message":"not found"}`
+ httpmock.RegisterResponder("GET", "https://api.qovery.com/missing-resource",
+ httpmock.NewStringResponder(404, errorBody))
+
+ client := &http.Client{}
+ req, _ := http.NewRequest("GET", "https://api.qovery.com/missing-resource", nil)
+ resp, err := client.Do(req)
+ assert.Nil(t, err)
+ defer func() { _ = resp.Body.Close() }()
+
+ var outBuf, errBuf bytes.Buffer
+ ok, writeErr := writeResponse(resp, false, &outBuf, &errBuf)
+ assert.Nil(t, writeErr)
+ assert.False(t, ok)
+ assert.Equal(t, "", outBuf.String()) // nothing on stdout
+ assert.Equal(t, errorBody, errBuf.String()) // body on stderr
+}
+
+// --- Scenario 7: --include flag output format ---
+func TestAPIIncludeFlag(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ responseBody := `{"results":[]}`
+ httpmock.RegisterResponder("GET", "https://api.qovery.com/organization",
+ func(req *http.Request) (*http.Response, error) {
+ resp := httpmock.NewStringResponse(200, responseBody)
+ resp.Header.Set("Content-Type", "application/json")
+ return resp, nil
+ })
+
+ client := &http.Client{}
+ req, _ := http.NewRequest("GET", "https://api.qovery.com/organization", nil)
+ resp, err := client.Do(req)
+ assert.Nil(t, err)
+ defer func() { _ = resp.Body.Close() }()
+
+ var outBuf, errBuf bytes.Buffer
+ ok, writeErr := writeResponse(resp, true, &outBuf, &errBuf)
+ assert.Nil(t, writeErr)
+ assert.True(t, ok)
+
+ stdout := outBuf.String()
+ // Must start with HTTP status line
+ assert.True(t, strings.HasPrefix(stdout, "HTTP/"), "stdout must start with HTTP/ status line, got: %q", stdout)
+ // Must contain Content-Type header
+ assert.Contains(t, stdout, "Content-Type: application/json")
+ // Must contain blank line before body
+ assert.Contains(t, stdout, "\n\n")
+ // Must contain body
+ assert.Contains(t, stdout, responseBody)
+}
+
+// --- Scenario 8: Custom -H header sent in request ---
+func TestAPICustomHeader(t *testing.T) {
+ t.Setenv("QOVERY_CLI_ACCESS_TOKEN", "fake-token")
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ var capturedHeader string
+ httpmock.RegisterResponder("GET", "https://api.qovery.com/organization",
+ func(req *http.Request) (*http.Response, error) {
+ capturedHeader = req.Header.Get("X-Request-Id")
+ return httpmock.NewStringResponse(200, `{}`), nil
+ })
+
+ apiMethod = ""
+ apiInput = ""
+ apiFields = []string{}
+ apiHeaders = []string{"X-Request-Id: abc123"}
+ apiInclude = false
+
+ captureOutput(func() {
+ runAPI(apiCmd, []string{"organization"})
+ })
+
+ assert.Equal(t, "abc123", capturedHeader)
+}
+
+// --- Scenario 9: Malformed -H header (pure unit test via validateAPIArgs) ---
+func TestAPIMalformedHeader(t *testing.T) {
+ assert.ErrorContains(t, validateAPIArgs("organization", "", "", nil, []string{"Badheader"}), "invalid header")
+ // Empty header name (": value") must also be rejected
+ assert.ErrorContains(t, validateAPIArgs("organization", "", "", nil, []string{": value"}), "invalid header")
+}
+
+// --- Scenario 10: Full URL rejected (pure unit test via validateAPIArgs) ---
+func TestAPIFullURLRejected(t *testing.T) {
+ assert.ErrorContains(t, validateAPIArgs("https://api.qovery.com/organization", "", "", nil, nil), "not a full URL")
+}
+
+// --- Scenario 11: Path normalisation (integration-style via runAPI) ---
+func TestAPIPathNormalisation(t *testing.T) {
+ t.Setenv("QOVERY_CLI_ACCESS_TOKEN", "fake-token")
+
+ cases := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {"leading-slash", "/organization", "https://api.qovery.com/organization"},
+ {"no-leading-slash", "organization", "https://api.qovery.com/organization"},
+ }
+
+ for _, tc := range cases {
+ tc := tc // capture range variable
+ t.Run(tc.name, func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ var requestedURL string
+ httpmock.RegisterResponder("GET", "https://api.qovery.com/organization",
+ func(req *http.Request) (*http.Response, error) {
+ requestedURL = req.URL.String()
+ return httpmock.NewStringResponse(200, `{}`), nil
+ })
+
+ apiMethod = ""
+ apiInput = ""
+ apiFields = []string{}
+ apiHeaders = []string{}
+ apiInclude = false
+
+ captureOutput(func() {
+ runAPI(apiCmd, []string{tc.input})
+ })
+
+ assert.Equal(t, tc.expected, requestedURL)
+ })
+ }
+}
+
+// --- Scenario 12: --field string value ---
+func TestAPIFieldString(t *testing.T) {
+ t.Setenv("QOVERY_CLI_ACCESS_TOKEN", "fake-token")
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ var capturedBody string
+ httpmock.RegisterResponder("POST", "https://api.qovery.com/organization",
+ func(req *http.Request) (*http.Response, error) {
+ b, _ := io.ReadAll(req.Body)
+ capturedBody = string(b)
+ return httpmock.NewStringResponse(200, `{}`), nil
+ })
+
+ apiMethod = ""
+ apiInput = ""
+ apiFields = []string{"name=myorg"}
+ apiHeaders = []string{}
+ apiInclude = false
+
+ captureOutput(func() {
+ runAPI(apiCmd, []string{"organization"})
+ })
+
+ var result map[string]any
+ _ = json.Unmarshal([]byte(capturedBody), &result)
+ assert.Equal(t, "myorg", result["name"])
+}
+
+// --- Scenario 13: --field bool coercion ---
+func TestAPIFieldBoolCoercion(t *testing.T) {
+ t.Setenv("QOVERY_CLI_ACCESS_TOKEN", "fake-token")
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ var capturedBody string
+ httpmock.RegisterResponder("POST", "https://api.qovery.com/organization",
+ func(req *http.Request) (*http.Response, error) {
+ b, _ := io.ReadAll(req.Body)
+ capturedBody = string(b)
+ return httpmock.NewStringResponse(200, `{}`), nil
+ })
+
+ apiMethod = ""
+ apiInput = ""
+ apiFields = []string{"enabled=true"}
+ apiHeaders = []string{}
+ apiInclude = false
+
+ captureOutput(func() {
+ runAPI(apiCmd, []string{"organization"})
+ })
+
+ var result map[string]any
+ _ = json.Unmarshal([]byte(capturedBody), &result)
+ assert.Equal(t, true, result["enabled"])
+}
+
+// --- Scenario 14: --field int coercion ---
+func TestAPIFieldIntCoercion(t *testing.T) {
+ t.Setenv("QOVERY_CLI_ACCESS_TOKEN", "fake-token")
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ var capturedBody string
+ httpmock.RegisterResponder("POST", "https://api.qovery.com/organization",
+ func(req *http.Request) (*http.Response, error) {
+ b, _ := io.ReadAll(req.Body)
+ capturedBody = string(b)
+ return httpmock.NewStringResponse(200, `{}`), nil
+ })
+
+ apiMethod = ""
+ apiInput = ""
+ apiFields = []string{"count=42"}
+ apiHeaders = []string{}
+ apiInclude = false
+
+ captureOutput(func() {
+ runAPI(apiCmd, []string{"organization"})
+ })
+
+ var result map[string]any
+ _ = json.Unmarshal([]byte(capturedBody), &result)
+ // After json.Unmarshal into map[string]any, all numbers become float64
+ assert.Equal(t, float64(42), result["count"])
+}
+
+// --- Scenario 15: --field multiple fields ---
+func TestAPIFieldMultipleFields(t *testing.T) {
+ t.Setenv("QOVERY_CLI_ACCESS_TOKEN", "fake-token")
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ var capturedBody string
+ httpmock.RegisterResponder("POST", "https://api.qovery.com/organization",
+ func(req *http.Request) (*http.Response, error) {
+ b, _ := io.ReadAll(req.Body)
+ capturedBody = string(b)
+ return httpmock.NewStringResponse(200, `{}`), nil
+ })
+
+ apiMethod = ""
+ apiInput = ""
+ apiFields = []string{"name=x", "count=1"}
+ apiHeaders = []string{}
+ apiInclude = false
+
+ captureOutput(func() {
+ runAPI(apiCmd, []string{"organization"})
+ })
+
+ var result map[string]any
+ _ = json.Unmarshal([]byte(capturedBody), &result)
+ assert.Equal(t, "x", result["name"])
+ assert.Equal(t, float64(1), result["count"])
+}
+
+// --- Scenario 16: --field + --input together (pure unit test via validateAPIArgs) ---
+func TestAPIFieldAndInputMutuallyExclusive(t *testing.T) {
+ assert.ErrorContains(t, validateAPIArgs("organization", "", "-", []string{"name=x"}, nil), "mutually exclusive")
+}
+
+// --- Scenario 17: Malformed --field entry (pure unit test via validateAPIArgs) ---
+func TestAPIMalformedField(t *testing.T) {
+ assert.ErrorContains(t, validateAPIArgs("organization", "", "", []string{"badfield"}, nil), "invalid field")
+ // Empty key ("=value") must also be rejected
+ assert.ErrorContains(t, validateAPIArgs("organization", "", "", []string{"=value"}, nil), "key must not be empty")
+}
+
+// --- Scenario 18: Placeholder substitution with org context ---
+func TestAPIPlaceholderSubstitution(t *testing.T) {
+ writeContextFile(t, "org-123", "proj-456", "env-789", "svc-abc")
+
+ result := substitutePathPlaceholders("organization/{organizationId}/project")
+ assert.Equal(t, "organization/org-123/project", result)
+}
+
+// --- Scenario 19: Missing/empty placeholder left as literal ---
+func TestAPIPlaceholderEmptyValue(t *testing.T) {
+ writeContextFile(t, "org-123", "", "env-789", "svc-abc")
+
+ result := substitutePathPlaceholders("project/{projectId}/env")
+ assert.Equal(t, "project/{projectId}/env", result)
+}
+
+// --- Scenario 20: Context unavailable â literal preserved ---
+func TestAPIPlaceholderContextUnavailable(t *testing.T) {
+ // Point HOME to a temp dir with no .qovery/context.json
+ tmpHome := t.TempDir()
+ t.Setenv("HOME", tmpHome)
+
+ result := substitutePathPlaceholders("organization/{organizationId}/project")
+ // GetCurrentContext() will error â zero-value context â empty string â literal preserved
+ assert.Equal(t, "organization/{organizationId}/project", result)
+}
+
+// --- Unit tests for coerceFieldValue ---
+func TestCoerceFieldValue(t *testing.T) {
+ tests := []struct {
+ input string
+ expected any
+ }{
+ {"true", true},
+ {"false", false},
+ {"42", int64(42)},
+ {"3.14", float64(3.14)},
+ {"42.0", float64(42.0)},
+ {"hello", "hello"},
+ {"123abc", "123abc"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.input, func(t *testing.T) {
+ assert.Equal(t, tc.expected, coerceFieldValue(tc.input))
+ })
+ }
+}
+
+// --- Unit tests for GetAPIBaseURL ---
+func TestGetAPIBaseURL(t *testing.T) {
+ t.Run("default URL when env var not set", func(t *testing.T) {
+ t.Setenv("QOVERY_API_URL", "")
+ assert.Equal(t, "https://api.qovery.com", utils.GetAPIBaseURL())
+ })
+
+ t.Run("env var URL used when set", func(t *testing.T) {
+ t.Setenv("QOVERY_API_URL", "https://staging.api.qovery.com")
+ assert.Equal(t, "https://staging.api.qovery.com", utils.GetAPIBaseURL())
+ })
+
+ t.Run("trailing slash stripped from env var", func(t *testing.T) {
+ t.Setenv("QOVERY_API_URL", "https://staging.api.qovery.com/")
+ assert.Equal(t, "https://staging.api.qovery.com", utils.GetAPIBaseURL())
+ })
+}
+
+// --- M4: Duplicate --field key rejected ---
+func TestAPIFieldDuplicateKey(t *testing.T) {
+ err := validateAPIArgs("organization", "", "", []string{"name=a", "name=b"}, nil)
+ assert.ErrorContains(t, err, "duplicate field key")
+}
diff --git a/cmd/application.go b/cmd/application.go
new file mode 100644
index 00000000..1744b3c6
--- /dev/null
+++ b/cmd/application.go
@@ -0,0 +1,32 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var applicationName string
+var applicationNames string
+var applicationCommitID string
+var applicationBranch string
+var targetApplicationName string
+var applicationCustomDomain string
+var applicationAutoDeploy bool
+
+var applicationCmd = &cobra.Command{
+ Use: "application",
+ Short: "Manage applications",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(applicationCmd)
+}
diff --git a/cmd/application_cancel.go b/cmd/application_cancel.go
new file mode 100644
index 00000000..98f8e8af
--- /dev/null
+++ b/cmd/application_cancel.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationCancelCmd = &cobra.Command{
+ Use: "cancel",
+ Short: "Cancel an application deployment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ msg, err := utils.CancelServiceDeployment(client, envId, application.Id, utils.ApplicationType, watchFlag)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if msg != "" {
+ utils.PrintlnInfo(msg)
+ return
+ }
+
+ utils.Println(fmt.Sprintf("Application %s deployment cancelled!", pterm.FgBlue.Sprintf("%s", applicationName)))
+ },
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationCancelCmd)
+ applicationCancelCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationCancelCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationCancelCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationCancelCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationCancelCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cancel until it's done or an error occurs")
+
+ _ = applicationCancelCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_clone.go b/cmd/application_clone.go
new file mode 100644
index 00000000..8ea86b5c
--- /dev/null
+++ b/cmd/application_clone.go
@@ -0,0 +1,116 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/go-errors/errors"
+ "io"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationCloneCmd = &cobra.Command{
+ Use: "clone",
+ Short: "Clone an application",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application, err := getApplicationContextResource(client, applicationName, envId)
+
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ targetProjectId := projectId // use same project as the source project
+ if targetProjectName != "" {
+
+ targetProjectId, err = getProjectContextResourceId(client, targetProjectName, organizationId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ targetEnvironmentId := envId // use same env as the source env
+ if targetEnvironmentName != "" {
+
+ targetEnvironmentId, err = getEnvironmentContextResourceId(client, targetEnvironmentName, targetProjectId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ if targetApplicationName == "" {
+ // use same app name as the source app
+ targetApplicationName = application.Name
+ }
+
+ req := qovery.CloneServiceRequest{
+ Name: targetApplicationName,
+ EnvironmentId: targetEnvironmentId,
+ }
+
+ clonedService, res, err := client.ApplicationsAPI.CloneApplication(context.Background(), application.Id).CloneServiceRequest(req).Execute()
+
+ if err != nil {
+ // print http body error message
+ if res.StatusCode != 200 {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ }
+
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ name := ""
+ if clonedService != nil {
+ name = clonedService.Name
+ }
+
+ utils.Println(fmt.Sprintf("Application %s cloned!", pterm.FgBlue.Sprintf("%s", name)))
+ },
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationCloneCmd)
+ applicationCloneCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationCloneCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationCloneCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationCloneCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationCloneCmd.Flags().StringVarP(&targetProjectName, "target-project", "", "", "Target Project Name")
+ applicationCloneCmd.Flags().StringVarP(&targetEnvironmentName, "target-environment", "", "", "Target Environment Name")
+ applicationCloneCmd.Flags().StringVarP(&targetApplicationName, "target-application-name", "", "", "Target Application Name")
+
+ _ = applicationCloneCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_delete.go b/cmd/application_delete.go
new file mode 100644
index 00000000..eda16837
--- /dev/null
+++ b/cmd/application_delete.go
@@ -0,0 +1,47 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete an application",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateApplicationArguments(applicationName, applicationNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ applicationList := buildApplicationListFromApplicationNames(client, envId, applicationName, applicationNames)
+ serviceIds := utils.Map(applicationList, func(application *qovery.Application) string {
+ return application.Id
+ })
+ _, err := client.EnvironmentActionsAPI.
+ DeleteSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ ApplicationIds: serviceIds,
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to delete application(s) %s has been queued...", pterm.FgBlue.Sprintf("%s%s", applicationName, applicationNames)))
+ WatchApplicationDeployment(client, envId, applicationList, watchFlag, qovery.STATEENUM_DELETED)
+ },
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationDeleteCmd)
+ applicationDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationDeleteCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationDeleteCmd.Flags().StringVarP(&applicationNames, "applications", "", "", "Application Names (comma separated) Example: --applications \"app1,app2,app3\"")
+ applicationDeleteCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch application status until it's ready or an error occurs")
+}
diff --git a/cmd/application_deploy.go b/cmd/application_deploy.go
new file mode 100644
index 00000000..7e2853d9
--- /dev/null
+++ b/cmd/application_deploy.go
@@ -0,0 +1,58 @@
+package cmd
+
+import (
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "time"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy an application",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateApplicationArguments(applicationName, applicationNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ // deploy multiple services
+ applicationList := buildApplicationListFromApplicationNames(client, envId, applicationName, applicationNames)
+ err := utils.DeployApplications(client, envId, applicationList, applicationCommitID)
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to deploy application(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", applicationName, applicationNames)))
+ WatchApplicationDeployment(client, envId, applicationList, watchFlag, qovery.STATEENUM_DEPLOYED)
+ },
+}
+
+func WatchApplicationDeployment(
+ client *qovery.APIClient,
+ envId string,
+ applications []*qovery.Application,
+ watchFlag bool,
+ finalServiceState qovery.StateEnum,
+) {
+ if watchFlag {
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent from race condition)
+ if len(applications) == 1 {
+ utils.WatchApplication(applications[0].Id, envId, client)
+ } else {
+ utils.WatchEnvironment(envId, finalServiceState, client)
+ }
+ }
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationDeployCmd)
+ applicationDeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationDeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationDeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationDeployCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationDeployCmd.Flags().StringVarP(&applicationNames, "applications", "", "", "Application Names (comma separated) Example: --applications \"app1,app2,app3\"")
+ applicationDeployCmd.Flags().StringVarP(&applicationCommitID, "commit-id", "c", "", "Application Commit ID")
+ applicationDeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch application status until it's ready or an error occurs")
+}
diff --git a/cmd/application_domain.go b/cmd/application_domain.go
new file mode 100644
index 00000000..6615b57c
--- /dev/null
+++ b/cmd/application_domain.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var applicationDomainCmd = &cobra.Command{
+ Use: "domain",
+ Short: "Manage application domains",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationDomainCmd)
+}
diff --git a/cmd/application_domain_create.go b/cmd/application_domain_create.go
new file mode 100644
index 00000000..cfc98119
--- /dev/null
+++ b/cmd/application_domain_create.go
@@ -0,0 +1,104 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/qovery/qovery-client-go"
+ "os"
+ "strconv"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var doNotGenerateCertificate bool
+var useCdn bool
+
+var applicationDomainCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create application custom domain",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.ApplicationCustomDomainAPI.ListApplicationCustomDomain(context.Background(), application.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomain := utils.FindByCustomDomainName(customDomains.GetResults(), applicationCustomDomain)
+ if customDomain != nil {
+ utils.PrintlnError(fmt.Errorf("custom domain %s already exists", applicationCustomDomain))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ generateCertificate := !doNotGenerateCertificate
+ req := qovery.CustomDomainRequest{
+ Domain: applicationCustomDomain,
+ GenerateCertificate: generateCertificate,
+ UseCdn: &useCdn,
+ }
+
+ createdDomain, _, err := client.ApplicationCustomDomainAPI.CreateApplicationCustomDomain(context.Background(), application.Id).CustomDomainRequest(req).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Custom domain %s has been created (generate certificate: %s)", pterm.FgBlue.Sprintf("%s", createdDomain.Domain), pterm.FgBlue.Sprintf("%s", strconv.FormatBool(createdDomain.GenerateCertificate))))
+ },
+}
+
+func init() {
+ applicationDomainCmd.AddCommand(applicationDomainCreateCmd)
+ applicationDomainCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationDomainCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationDomainCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationDomainCreateCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationDomainCreateCmd.Flags().StringVarP(&applicationCustomDomain, "domain", "", "", "Custom Domain ")
+ applicationDomainCreateCmd.Flags().BoolVarP(&doNotGenerateCertificate, "do-not-generate-certificate", "", false, "Do Not Generate Certificate")
+ applicationDomainCreateCmd.Flags().BoolVarP(&useCdn, "is-behind-a-cdn", "", false, "Custom Domain is behind a CDN")
+
+ _ = applicationDomainCreateCmd.MarkFlagRequired("application")
+ _ = applicationDomainCreateCmd.MarkFlagRequired("domain")
+}
diff --git a/cmd/application_domain_delete.go b/cmd/application_domain_delete.go
new file mode 100644
index 00000000..2c0d5edb
--- /dev/null
+++ b/cmd/application_domain_delete.go
@@ -0,0 +1,90 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationDomainDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete application custom domain",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.ApplicationCustomDomainAPI.ListApplicationCustomDomain(context.Background(), application.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomain := utils.FindByCustomDomainName(customDomains.GetResults(), applicationCustomDomain)
+ if customDomain == nil {
+ utils.PrintlnError(fmt.Errorf("custom domain %s does not exist", applicationCustomDomain))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ _, err = client.ApplicationCustomDomainAPI.DeleteCustomDomain(context.Background(), application.Id, customDomain.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Custom domain %s has been deleted", pterm.FgBlue.Sprintf("%s", applicationCustomDomain)))
+ },
+}
+
+func init() {
+ applicationDomainCmd.AddCommand(applicationDomainDeleteCmd)
+ applicationDomainDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationDomainDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationDomainDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationDomainDeleteCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationDomainDeleteCmd.Flags().StringVarP(&applicationCustomDomain, "domain", "", "", "Custom Domain ")
+
+ _ = applicationDomainDeleteCmd.MarkFlagRequired("application")
+ _ = applicationDomainDeleteCmd.MarkFlagRequired("domain")
+}
diff --git a/cmd/application_domain_edit.go b/cmd/application_domain_edit.go
new file mode 100644
index 00000000..c28f45ed
--- /dev/null
+++ b/cmd/application_domain_edit.go
@@ -0,0 +1,101 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/qovery/qovery-client-go"
+ "os"
+ "strconv"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationDomainEditCmd = &cobra.Command{
+ Use: "edit",
+ Short: "Edit application custom domain",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.ApplicationCustomDomainAPI.ListApplicationCustomDomain(context.Background(), application.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomain := utils.FindByCustomDomainName(customDomains.GetResults(), applicationCustomDomain)
+ if customDomain == nil {
+ utils.PrintlnError(fmt.Errorf("custom domain %s does not exist", applicationCustomDomain))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ generateCertificate := !doNotGenerateCertificate
+ req := qovery.CustomDomainRequest{
+ Domain: applicationCustomDomain,
+ GenerateCertificate: generateCertificate,
+ UseCdn: &useCdn,
+ }
+
+ editedDomain, _, err := client.ApplicationCustomDomainAPI.EditCustomDomain(context.Background(), application.Id, customDomain.Id).CustomDomainRequest(req).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Custom domain %s has been edited (generate certificate: %s)", pterm.FgBlue.Sprintf("%s", editedDomain.Domain), pterm.FgBlue.Sprintf("%s", strconv.FormatBool(editedDomain.GenerateCertificate))))
+ },
+}
+
+func init() {
+ applicationDomainCmd.AddCommand(applicationDomainEditCmd)
+ applicationDomainEditCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationDomainEditCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationDomainEditCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationDomainEditCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationDomainEditCmd.Flags().StringVarP(&applicationCustomDomain, "domain", "", "", "Custom Domain ")
+ applicationDomainEditCmd.Flags().BoolVarP(&doNotGenerateCertificate, "do-not-generate-certificate", "", false, "Do Not Generate Certificate")
+ applicationDomainEditCmd.Flags().BoolVarP(&useCdn, "is-behind-a-cdn", "", false, "Custom Domain is behind a CDN")
+
+ _ = applicationDomainEditCmd.MarkFlagRequired("application")
+ _ = applicationDomainEditCmd.MarkFlagRequired("domain")
+}
diff --git a/cmd/application_domain_list.go b/cmd/application_domain_list.go
new file mode 100644
index 00000000..f6dc2150
--- /dev/null
+++ b/cmd/application_domain_list.go
@@ -0,0 +1,151 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var applicationDomainListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List application domains",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.ApplicationCustomDomainAPI.ListApplicationCustomDomain(context.Background(), application.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ links, _, err := client.ApplicationMainCallsAPI.ListApplicationLinks(context.Background(), application.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(getApplicationDomainJsonOutput(links.GetResults(), customDomains.GetResults()))
+ return
+ }
+
+ customDomainsSet := make(map[string]bool)
+ var data [][]string
+
+ for _, customDomain := range customDomains.GetResults() {
+ customDomainsSet[customDomain.Domain] = true
+
+ data = append(data, []string{
+ customDomain.Id,
+ "CUSTOM_DOMAIN",
+ customDomain.Domain,
+ *customDomain.ValidationDomain,
+ strconv.FormatBool(customDomain.GenerateCertificate),
+ })
+ }
+
+ for _, link := range links.GetResults() {
+ domain := strings.ReplaceAll(link.Url, "https://", "")
+ if !customDomainsSet[domain] {
+ data = append(data, []string{
+ "N/A",
+ "BUILT_IN_DOMAIN",
+ domain,
+ "N/A",
+ "N/A",
+ })
+ }
+ }
+
+ err = utils.PrintTable([]string{"Id", "Type", "Domain", "Validation Domain", "Generate Certificate"}, data)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getApplicationDomainJsonOutput(links []qovery.Link, domains []qovery.CustomDomain) string {
+ var results []interface{}
+
+ for _, link := range links {
+ results = append(results, map[string]interface{}{
+ "id": nil,
+ "type": "BUILT_IN_DOMAIN",
+ "domain": strings.ReplaceAll(link.Url, "https://", ""),
+ "validation_domain": nil,
+ })
+ }
+
+ for _, domain := range domains {
+ results = append(results, map[string]interface{}{
+ "id": domain.Id,
+ "type": "CUSTOM_DOMAIN",
+ "domain": domain.Domain,
+ "validation_domain": *domain.ValidationDomain,
+ })
+ }
+
+ j, err := json.Marshal(results)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ applicationDomainCmd.AddCommand(applicationDomainListCmd)
+ applicationDomainListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationDomainListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationDomainListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationDomainListCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationDomainListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = applicationDomainListCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_env.go b/cmd/application_env.go
new file mode 100644
index 00000000..a592eec1
--- /dev/null
+++ b/cmd/application_env.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var applicationEnvCmd = &cobra.Command{
+ Use: "env",
+ Short: "Manage application environment variables and secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationEnvCmd)
+}
diff --git a/cmd/application_env_alias.go b/cmd/application_env_alias.go
new file mode 100644
index 00000000..af34ae98
--- /dev/null
+++ b/cmd/application_env_alias.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var applicationEnvAliasCmd = &cobra.Command{
+ Use: "alias",
+ Short: "Manage application environment variable and secret aliases",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ applicationEnvCmd.AddCommand(applicationEnvAliasCmd)
+}
diff --git a/cmd/application_env_alias_create.go b/cmd/application_env_alias_create.go
new file mode 100644
index 00000000..ee4ece42
--- /dev/null
+++ b/cmd/application_env_alias_create.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationEnvAliasCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create application environment variable or secret alias",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceAlias(client, projectId, envId, application.Id, utils.ApplicationType, utils.Key, utils.Alias, utils.ApplicationScope)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Alias %s has been created", pterm.FgBlue.Sprintf("%s", utils.Alias)))
+ },
+}
+
+func init() {
+ applicationEnvAliasCmd.AddCommand(applicationEnvAliasCreateCmd)
+ applicationEnvAliasCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationEnvAliasCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationEnvAliasCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationEnvAliasCreateCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationEnvAliasCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ applicationEnvAliasCreateCmd.Flags().StringVarP(&utils.Alias, "alias", "", "", "Environment variable or secret alias")
+ applicationEnvAliasCreateCmd.Flags().StringVarP(&utils.ApplicationScope, "scope", "", "APPLICATION", "Scope of this alias ")
+
+ _ = applicationEnvAliasCreateCmd.MarkFlagRequired("key")
+ _ = applicationEnvAliasCreateCmd.MarkFlagRequired("alias")
+ _ = applicationEnvAliasCreateCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_env_create.go b/cmd/application_env_create.go
new file mode 100644
index 00000000..758e70a5
--- /dev/null
+++ b/cmd/application_env_create.go
@@ -0,0 +1,79 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationEnvCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create application environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceVariable(client, projectId, envId, application.Id, utils.ApplicationScope, utils.Key, utils.Value, utils.IsSecret)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ applicationEnvCmd.AddCommand(applicationEnvCreateCmd)
+ applicationEnvCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationEnvCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationEnvCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationEnvCreateCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationEnvCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ applicationEnvCreateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+ applicationEnvCreateCmd.Flags().StringVarP(&utils.ApplicationScope, "scope", "", "APPLICATION", "Scope of this env var ")
+ applicationEnvCreateCmd.Flags().BoolVarP(&utils.IsSecret, "secret", "", false, "This environment variable is a secret")
+
+ _ = applicationEnvCreateCmd.MarkFlagRequired("key")
+ _ = applicationEnvCreateCmd.MarkFlagRequired("value")
+ _ = applicationEnvCreateCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_env_delete.go b/cmd/application_env_delete.go
new file mode 100644
index 00000000..1424e65b
--- /dev/null
+++ b/cmd/application_env_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationEnvDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete application environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, application.Id, utils.ApplicationType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Variable %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ applicationEnvCmd.AddCommand(applicationEnvDeleteCmd)
+ applicationEnvDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationEnvDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationEnvDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationEnvDeleteCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationEnvDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+
+ _ = applicationEnvDeleteCmd.MarkFlagRequired("key")
+ _ = applicationEnvDeleteCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_env_list.go b/cmd/application_env_list.go
new file mode 100644
index 00000000..57eee04b
--- /dev/null
+++ b/cmd/application_env_list.go
@@ -0,0 +1,100 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var applicationEnvListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List application environment variables",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVars, err := utils.ListServiceVariables(
+ client,
+ application.Id,
+ utils.ApplicationType,
+ )
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVarLines := utils.NewEnvVarLines()
+ var variables []utils.EnvVarLineOutput
+
+ for _, envVar := range envVars {
+ s := utils.FromEnvironmentVariableToEnvVarLineOutput(envVar)
+ variables = append(variables, s)
+ envVarLines.Add(s)
+ }
+
+ if jsonFlag {
+ utils.Println(utils.GetEnvVarJsonOutput(variables, utils.SortKeys))
+ return
+ }
+
+ err = utils.PrintTable(envVarLines.Header(utils.PrettyPrint), envVarLines.Lines(utils.ShowValues, utils.PrettyPrint, utils.SortKeys))
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func init() {
+ applicationEnvCmd.AddCommand(applicationEnvListCmd)
+ applicationEnvListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationEnvListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationEnvListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationEnvListCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationEnvListCmd.Flags().BoolVarP(&utils.ShowValues, "show-values", "", false, "Show env var values")
+ applicationEnvListCmd.Flags().BoolVarP(&utils.PrettyPrint, "pretty-print", "", false, "Pretty print output")
+ applicationEnvListCmd.Flags().BoolVarP(&utils.SortKeys, "sort", "", false, "Sort environment variables by key")
+ applicationEnvListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = applicationEnvListCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_env_override.go b/cmd/application_env_override.go
new file mode 100644
index 00000000..f2df75da
--- /dev/null
+++ b/cmd/application_env_override.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var applicationEnvOverrideCmd = &cobra.Command{
+ Use: "override",
+ Short: "Manage application environment variable and secret overrides",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ applicationEnvCmd.AddCommand(applicationEnvOverrideCmd)
+}
diff --git a/cmd/application_env_override_create.go b/cmd/application_env_override_create.go
new file mode 100644
index 00000000..9d18601a
--- /dev/null
+++ b/cmd/application_env_override_create.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationEnvOverrideCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Override application environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceOverride(client, projectId, envId, application.Id, utils.ApplicationType, utils.Key, utils.Value, utils.ApplicationScope)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("%s has been overidden", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ applicationEnvOverrideCmd.AddCommand(applicationEnvOverrideCreateCmd)
+ applicationEnvOverrideCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationEnvOverrideCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationEnvOverrideCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationEnvOverrideCreateCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationEnvOverrideCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ applicationEnvOverrideCreateCmd.Flags().StringVarP(&utils.Value, "value", "", "", "Environment variable or secret value")
+ applicationEnvOverrideCreateCmd.Flags().StringVarP(&utils.ApplicationScope, "scope", "", "APPLICATION", "Scope of this alias ")
+
+ _ = applicationEnvOverrideCreateCmd.MarkFlagRequired("key")
+ _ = applicationEnvOverrideCreateCmd.MarkFlagRequired("application")
+ _ = applicationEnvOverrideCreateCmd.MarkFlagRequired("value")
+}
diff --git a/cmd/application_env_update.go b/cmd/application_env_update.go
new file mode 100644
index 00000000..c25b3528
--- /dev/null
+++ b/cmd/application_env_update.go
@@ -0,0 +1,77 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationEnvUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update application environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceVariable(client, utils.Key, utils.Value, application.Id, utils.ApplicationType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ applicationEnvCmd.AddCommand(applicationEnvUpdateCmd)
+ applicationEnvUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationEnvUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationEnvUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationEnvUpdateCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationEnvUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ applicationEnvUpdateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+
+ _ = applicationEnvUpdateCmd.MarkFlagRequired("key")
+ _ = applicationEnvUpdateCmd.MarkFlagRequired("value")
+ _ = applicationEnvUpdateCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_external_secret.go b/cmd/application_external_secret.go
new file mode 100644
index 00000000..922a35bf
--- /dev/null
+++ b/cmd/application_external_secret.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var applicationExternalSecretCmd = &cobra.Command{
+ Use: "external-secret",
+ Short: "Manage application external secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationExternalSecretCmd)
+}
diff --git a/cmd/application_external_secret_create.go b/cmd/application_external_secret_create.go
new file mode 100644
index 00000000..497ff18f
--- /dev/null
+++ b/cmd/application_external_secret_create.go
@@ -0,0 +1,88 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationExternalSecretCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create application external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceExternalSecret(client, projectId, envId, application.Id, utils.ApplicationScope, utils.Key, utils.Reference, secretManagerAccessId, utils.MountPath)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ applicationExternalSecretCmd.AddCommand(applicationExternalSecretCreateCmd)
+ applicationExternalSecretCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationExternalSecretCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationExternalSecretCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationExternalSecretCreateCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationExternalSecretCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ applicationExternalSecretCreateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "Reference to the secret in the secrets provider")
+ applicationExternalSecretCreateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "Secret manager access name")
+ applicationExternalSecretCreateCmd.Flags().StringVarP(&utils.ApplicationScope, "scope", "", "APPLICATION", "Scope of this external secret ")
+ applicationExternalSecretCreateCmd.Flags().StringVarP(&utils.MountPath, "mount-path", "", "", "Path where the secret will be mounted as a file")
+
+ _ = applicationExternalSecretCreateCmd.MarkFlagRequired("key")
+ _ = applicationExternalSecretCreateCmd.MarkFlagRequired("reference")
+ _ = applicationExternalSecretCreateCmd.MarkFlagRequired("secret-manager-access-name")
+ _ = applicationExternalSecretCreateCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_external_secret_delete.go b/cmd/application_external_secret_delete.go
new file mode 100644
index 00000000..543e959c
--- /dev/null
+++ b/cmd/application_external_secret_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationExternalSecretDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete application external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, application.Id, utils.ApplicationType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ applicationExternalSecretCmd.AddCommand(applicationExternalSecretDeleteCmd)
+ applicationExternalSecretDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationExternalSecretDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationExternalSecretDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationExternalSecretDeleteCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationExternalSecretDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+
+ _ = applicationExternalSecretDeleteCmd.MarkFlagRequired("key")
+ _ = applicationExternalSecretDeleteCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_external_secret_update.go b/cmd/application_external_secret_update.go
new file mode 100644
index 00000000..59942289
--- /dev/null
+++ b/cmd/application_external_secret_update.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationExternalSecretUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update application external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceExternalSecret(client, utils.Key, utils.Reference, secretManagerAccessId, application.Id, utils.ApplicationType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ applicationExternalSecretCmd.AddCommand(applicationExternalSecretUpdateCmd)
+ applicationExternalSecretUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationExternalSecretUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationExternalSecretUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationExternalSecretUpdateCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationExternalSecretUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ applicationExternalSecretUpdateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "New reference to the secret in the secrets provider")
+ applicationExternalSecretUpdateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "New secret manager access name")
+
+ _ = applicationExternalSecretUpdateCmd.MarkFlagRequired("key")
+ _ = applicationExternalSecretUpdateCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_list.go b/cmd/application_list.go
new file mode 100644
index 00000000..95bfdb40
--- /dev/null
+++ b/cmd/application_list.go
@@ -0,0 +1,104 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/qovery/qovery-client-go"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var applicationListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List applications",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ var data [][]string
+
+ if jsonFlag {
+ utils.Println(getAppJsonOutput(applications.GetResults(), statuses))
+ return
+ }
+
+ for _, application := range applications.GetResults() {
+ data = append(data, []string{application.Id, application.Name, "Application",
+ utils.FindStatusTextWithColor(statuses.GetApplications(), application.Id), application.UpdatedAt.String()})
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name", "Type", "Status", "Last Update"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getAppJsonOutput(applications []qovery.Application, statuses *qovery.EnvironmentStatuses) string {
+ var results []interface{}
+
+ for _, application := range applications {
+ results = append(results, map[string]interface{}{
+ "id": application.Id,
+ "name": application.Name,
+ "type": "Application",
+ "status": utils.FindStatus(statuses.GetApplications(), application.Id),
+ "last_update": application.UpdatedAt.String(),
+ })
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationListCmd)
+ applicationListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/application_redeploy.go b/cmd/application_redeploy.go
new file mode 100644
index 00000000..0e343023
--- /dev/null
+++ b/cmd/application_redeploy.go
@@ -0,0 +1,43 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var applicationRedeployCmd = &cobra.Command{
+ Use: "redeploy",
+ Short: "Redeploy an application",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateApplicationArguments(applicationName, applicationNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ applicationList := buildApplicationListFromApplicationNames(client, envId, applicationName, applicationNames)
+
+ _, _, err := client.ApplicationActionsAPI.DeployApplication(context.Background(), applicationList[0].Id).
+ DeployRequest(qovery.DeployRequest{GitCommitId: *applicationList[0].GitRepository.DeployedCommitId}).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to redeploy application(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", applicationName, applicationNames)))
+ WatchApplicationDeployment(client, envId, applicationList, watchFlag, qovery.STATEENUM_RESTARTED)
+ },
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationRedeployCmd)
+ applicationRedeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationRedeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationRedeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationRedeployCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationRedeployCmd.Flags().StringVarP(&applicationCommitID, "commit-id", "c", "", "Application Commit ID")
+ applicationRedeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch application status until it's ready or an error occurs")
+
+ _ = applicationRedeployCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/application_stop.go b/cmd/application_stop.go
new file mode 100644
index 00000000..0ab28eb0
--- /dev/null
+++ b/cmd/application_stop.go
@@ -0,0 +1,104 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+ "strings"
+)
+
+var applicationStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop an application",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateApplicationArguments(applicationName, applicationNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ applicationList := buildApplicationListFromApplicationNames(client, envId, applicationName, applicationNames)
+ serviceIds := utils.Map(applicationList, func(application *qovery.Application) string {
+ return application.Id
+ })
+ _, err := client.EnvironmentActionsAPI.
+ StopSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ ApplicationIds: serviceIds,
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to stop application(s) %s has been queued...", pterm.FgBlue.Sprintf("%s%s", applicationName, applicationNames)))
+ WatchApplicationDeployment(client, envId, applicationList, watchFlag, qovery.STATEENUM_STOPPED)
+ },
+}
+
+func buildApplicationListFromApplicationNames(
+ client *qovery.APIClient,
+ environmentId string,
+ applicationName string,
+ applicationNames string,
+) []*qovery.Application {
+ var applicationList []*qovery.Application
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ if applicationName != "" {
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ applicationList = append(applicationList, application)
+ }
+ if applicationNames != "" {
+ for _, applicationName := range strings.Split(applicationNames, ",") {
+ trimmedApplicationName := strings.TrimSpace(applicationName)
+ application := utils.FindByApplicationName(applications.GetResults(), trimmedApplicationName)
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ applicationList = append(applicationList, application)
+ }
+ }
+
+ return applicationList
+}
+
+func validateApplicationArguments(applicationName string, applicationNames string) {
+ if applicationName == "" && applicationNames == "" {
+ utils.PrintlnError(fmt.Errorf("use either --application \"\" or --applications \", \" but not both at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if applicationName != "" && applicationNames != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --application and --applications at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
+
+func checkError(err error) {
+ utils.CheckError(err)
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationStopCmd)
+ applicationStopCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationStopCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationStopCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationStopCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationStopCmd.Flags().StringVarP(&applicationNames, "applications", "", "", "Application Names (comma separated) Example: --applications \"app1,app2,app3\"")
+ applicationStopCmd.Flags().StringVarP(&applicationCommitID, "commit-id", "c", "", "Application Commit ID")
+ applicationStopCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch application status until it's ready or an error occurs")
+}
diff --git a/cmd/application_update.go b/cmd/application_update.go
new file mode 100644
index 00000000..b37bf22b
--- /dev/null
+++ b/cmd/application_update.go
@@ -0,0 +1,120 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var applicationUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update an application",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", applicationName))
+ utils.PrintlnInfo("You can list all applications with: qovery application list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ var storage []qovery.ServiceStorageRequestStorageInner
+ for _, s := range application.Storage {
+ storage = append(storage, qovery.ServiceStorageRequestStorageInner{
+ Id: &s.Id,
+ Type: s.Type,
+ Size: s.Size,
+ MountPoint: s.MountPoint,
+ })
+ }
+
+ req := qovery.ApplicationEditRequest{
+ Storage: storage,
+ Name: &application.Name,
+ Description: application.Description,
+ GitRepository: &qovery.ApplicationGitRepositoryRequest{
+ Branch: application.GitRepository.Branch,
+ GitTokenId: application.GitRepository.GitTokenId,
+ RootPath: application.GitRepository.RootPath,
+ Url: application.GitRepository.Url,
+ Provider: application.GitRepository.Provider,
+ },
+ BuildMode: application.BuildMode,
+ DockerfilePath: application.DockerfilePath,
+ Cpu: application.Cpu,
+ Memory: application.Memory,
+ MinRunningInstances: application.MinRunningInstances,
+ MaxRunningInstances: application.MaxRunningInstances,
+ Healthchecks: application.Healthchecks,
+ AutoPreview: application.AutoPreview,
+ Ports: application.Ports,
+ Arguments: application.Arguments,
+ Entrypoint: application.Entrypoint,
+ AutoDeploy: *qovery.NewNullableBool(application.AutoDeploy),
+ Autoscaling: utils.ConvertAutoscalingResponseToRequest(application.Autoscaling),
+ }
+
+ if applicationBranch != "" {
+ req.GitRepository.Branch = &applicationBranch
+ }
+
+ if cmd.Flags().Changed("auto-deploy") {
+ req.AutoDeploy = *qovery.NewNullableBool(&applicationAutoDeploy)
+ }
+
+ _, _, err = client.ApplicationMainCallsAPI.EditApplication(context.Background(), application.Id).ApplicationEditRequest(req).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Application %s updated!", pterm.FgBlue.Sprintf("%s", applicationName)))
+ },
+}
+
+func init() {
+ applicationCmd.AddCommand(applicationUpdateCmd)
+ applicationUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ applicationUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ applicationUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ applicationUpdateCmd.Flags().StringVarP(&applicationName, "application", "n", "", "Application Name")
+ applicationUpdateCmd.Flags().StringVarP(&applicationBranch, "branch", "", "", "Application Git Branch")
+ applicationUpdateCmd.Flags().BoolVarP(&applicationAutoDeploy, "auto-deploy", "", false, "Application Auto Deploy")
+
+ _ = applicationUpdateCmd.MarkFlagRequired("application")
+}
diff --git a/cmd/audit_log.go b/cmd/audit_log.go
new file mode 100644
index 00000000..147e4fc4
--- /dev/null
+++ b/cmd/audit_log.go
@@ -0,0 +1,27 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ auditLogCmd = &cobra.Command{
+ Use: "audit-log",
+ Short: "Interact with audit logs",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+ }
+)
+
+func init() {
+ rootCmd.AddCommand(auditLogCmd)
+}
diff --git a/cmd/audit_log_download.go b/cmd/audit_log_download.go
new file mode 100644
index 00000000..bb3cfbec
--- /dev/null
+++ b/cmd/audit_log_download.go
@@ -0,0 +1,80 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/qovery/qovery-cli/pkg/auditlog"
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ auditLogDowndloadCmd = &cobra.Command{
+ Use: "export",
+ Short: "Export audit logs",
+ Long: `> Description
+-------------
+This command provides an easy way to download audit logs.
+Date parameters must follow the ISO-8601 format, i.e:
+* 2025-10-02T01:04:45+12:00 is valid
+* 2025-10-02T01:04:45Z is valid
+* 2025-10-02 01:04:45Z is invalid (missing T separator)
+
+> Examples
+----------
+* Search from a specific date to now:
+qovery audit-log export --from-date 2025-09-01T01:04:45+02:00
+
+* Search between a range of dates
+qovery audit-log export --from-date 2025-09-01T01:04:45Z --to-date 2025-09-02T02:00:00Z
+`,
+ Run: func(cmd *cobra.Command, args []string) {
+ downloadAuditLogs()
+ },
+ }
+
+ fromDate string
+ toDate string
+)
+
+func init() {
+ auditLogDowndloadCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ auditLogDowndloadCmd.Flags().StringVarP(&fromDate, "from-date", "f", "", "Start date for the search following ISO-8601 format")
+ auditLogDowndloadCmd.Flags().StringVarP(&toDate, "to-date", "t", "", "End date for the search following ISO-8601 format (defaulted to 'now')")
+
+ _ = auditLogDowndloadCmd.MarkFlagRequired("from-date")
+
+ auditLogCmd.AddCommand(auditLogDowndloadCmd)
+}
+
+func downloadAuditLogs() {
+ // Get organization ID
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ organizationId, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
+ checkError(err)
+
+ org, _, err := client.OrganizationMainCallsAPI.GetOrganization(context.Background(), organizationId).Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Your organization plan provides %.0f days of audit log history", org.OrganizationPlan.GetAuditLogsRetentionInDays()))
+
+ // Get access token
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ // Create audit log service
+ auditLogService := auditlog.NewService()
+
+ // Download audit logs
+ options := auditlog.DownloadOptions{
+ OrganizationID: organizationId,
+ FromDate: fromDate,
+ ToDate: toDate,
+ TokenType: string(tokenType),
+ Token: string(token),
+ }
+
+ err = auditLogService.DownloadAuditLogs(options)
+ checkError(err)
+}
diff --git a/cmd/auth.go b/cmd/auth.go
index d1374389..c6642754 100644
--- a/cmd/auth.go
+++ b/cmd/auth.go
@@ -1,276 +1,26 @@
package cmd
import (
- "context"
- "crypto/sha256"
- "encoding/base64"
- "encoding/json"
- "errors"
- "fmt"
- "github.com/pkg/browser"
+ "github.com/spf13/cobra"
+
"github.com/qovery/qovery-cli/pkg"
"github.com/qovery/qovery-cli/utils"
- "github.com/spf13/cobra"
- "math/rand"
- "net/http"
- "net/url"
- "os"
- "strconv"
- "strings"
- "time"
)
var headless bool
+var skipVersionCheck bool
var authCmd = &cobra.Command{
Use: "auth",
Short: "Log in to Qovery",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)
- DoRequestUserToAuthenticate(headless)
+ pkg.DoRequestUserToAuthenticate(headless, skipVersionCheck)
},
}
func init() {
rootCmd.AddCommand(authCmd)
authCmd.Flags().BoolVarP(&headless, "headless", "", false, "Headless auth")
-}
-
-const (
- httpAuthPort = 10999
- oAuthQoveryUrl = "https://auth.qovery.com/login?code_challenge_method=S256&scope=%s&client=%s&protocol=oauth2&response_type=%s&audience=%s&redirect_uri=%s&code_challenge=%s"
-)
-
-var (
- oAuthUrlParamValueClient = "MJ2SJpu12PxIzgmc5z5Y7N8m5MnaF7Y0"
- oAuthUrlParamValueHeadlessClient = "f9drkTNpxsEw2VU2PVDrxhyT3vVuFT0Y"
- oAuthUrlParamValueAudience = "https://core.qovery.com"
- oAuthUrlParamValueResponseType = "code"
- oAuthUrlParamValueScopes = "offline_access openid profile email"
- oAuthUrlParamValueRedirect = "http://localhost:" + strconv.Itoa(httpAuthPort) + "/authorization"
- oAuthTokenEndpoint = "https://auth.qovery.com/oauth/token"
-)
-
-type TokensResponse struct {
- AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token"`
-}
-
-func DoRequestUserToAuthenticate(headless bool) {
- qoveryConsoleUrl := "https://console.qovery.com"
-
- available, message, _ := pkg.CheckAvailableNewVersion()
- if available {
- fmt.Println(message)
- }
-
- if headless {
- runHeadlessFlow()
- return
- }
-
- verifier := createCodeVerifier()
- challenge, err := createCodeChallengeS256(verifier)
- if err != nil {
- utils.PrintlnError(errors.New("Can not create authorization code challenge. Please contact the #support at 'https://discord.qovery.com'. "))
- os.Exit(0)
- }
- // TODO link to web auth
- _ = browser.OpenURL(fmt.Sprintf(oAuthQoveryUrl, url.QueryEscape(oAuthUrlParamValueScopes), oAuthUrlParamValueClient, url.QueryEscape(oAuthUrlParamValueResponseType),
- url.QueryEscape(oAuthUrlParamValueAudience), url.QueryEscape(oAuthUrlParamValueRedirect), challenge))
-
- fmt.Println("\nOpening your browser, waiting for your authentication... ")
-
- srv := &http.Server{Addr: fmt.Sprintf("localhost:%d", httpAuthPort)}
-
- http.HandleFunc("/authorization", func(writer http.ResponseWriter, request *http.Request) {
- js := fmt.Sprintf(``, httpAuthPort)
-
- _, _ = writer.Write([]byte(js))
- _, _ = writer.Write([]byte("Authentication successful, you'll be redirected to Qovery console. If it's not the case, click on this link: " + qoveryConsoleUrl + ""))
- })
-
- http.HandleFunc("/authorization/valid", func(writer http.ResponseWriter, request *http.Request) {
- code := request.URL.Query()["code"][0]
- res, err := http.PostForm(oAuthTokenEndpoint, url.Values{
- "grant_type": {"authorization_code"},
- "client_id": {oAuthUrlParamValueClient},
- "code": {code},
- "redirect_uri": {oAuthUrlParamValueRedirect},
- "code_verifier": {verifier},
- })
-
- if err != nil {
- utils.PrintlnError(errors.New("Authentication unsuccessful. Try again later or contact #support on 'https://discord.qovery.com'. "))
- os.Exit(0)
- } else {
- defer res.Body.Close()
- tokens := TokensResponse{}
- err := json.NewDecoder(res.Body).Decode(&tokens)
- if err != nil {
- utils.PrintlnError(errors.New("Authentication unsuccessful. Try again later or contact #support on 'https://discord.qovery.com'. "))
- os.Exit(0)
- }
- expiredAt := tokenExpiration()
- _ = utils.SetAccessToken(utils.AccessToken(tokens.AccessToken), expiredAt)
- _ = utils.SetRefreshToken(utils.RefreshToken(tokens.RefreshToken))
- utils.PrintlnInfo("Success!")
- }
-
- go func() {
- time.Sleep(time.Second)
- if err := srv.Shutdown(context.TODO()); err != nil {
- utils.PrintlnError(err)
- }
- }()
- })
-
- _ = srv.ListenAndServe()
-}
-
-func createCodeVerifier() string {
- length := 64
- r := rand.New(rand.NewSource(time.Now().UnixNano()))
- b := make([]byte, length)
- for i := 0; i < length; i++ {
- b[i] = byte(r.Intn(255))
- }
- return encode(b)
-}
-
-func createCodeChallengeS256(verifier string) (string, error) {
- h := sha256.New()
- _, err := h.Write([]byte(verifier))
- if err != nil {
- return "", err
- }
- return encode(h.Sum(nil)), nil
-}
-
-func encode(msg []byte) string {
- encoded := base64.StdEncoding.EncodeToString(msg)
- encoded = strings.Replace(encoded, "+", "-", -1)
- encoded = strings.Replace(encoded, "/", "_", -1)
- encoded = strings.Replace(encoded, "=", "", -1)
- return encoded
-}
-
-func runHeadlessFlow() {
- parameters := deviceFlowParameters()
- requestDeviceActivationWith(parameters)
- start := time.Now()
-
- fmt.Println("Waiting for code confirmation...")
-
- for time.Since(start).Seconds() < float64(parameters.ExpiresIn) {
- time.Sleep(time.Second * time.Duration(parameters.Interval))
- tokens, err := getTokensWith(parameters)
-
- if err == nil {
- expiredAt := tokenExpiration()
- _ = utils.SetRefreshToken(utils.RefreshToken(tokens.RefreshToken))
- _ = utils.SetAccessToken(utils.AccessToken(tokens.AccessToken), expiredAt)
- utils.PrintlnInfo("Success!")
- return
- }
- }
-
- fmt.Println("Code has expired! ")
- os.Exit(0)
-}
-
-func tokenExpiration() time.Time {
- oneHour := time.Second * time.Duration(3599)
- return time.Now().Local().Add(oneHour)
-}
-
-func deviceFlowParameters() DeviceFlowParameters {
- endpoint := "https://auth.qovery.com/oauth/device/code"
- payload := strings.NewReader(fmt.Sprintf("client_id=%s&scope=%s&audience=%s&redirect_uri=%s", url.QueryEscape(oAuthUrlParamValueHeadlessClient), url.QueryEscape(oAuthUrlParamValueScopes), url.QueryEscape(oAuthUrlParamValueAudience), url.QueryEscape(oAuthUrlParamValueRedirect)))
- req, err := http.NewRequest("POST", endpoint, payload)
-
- if err != nil {
- printContactSupportMessage("Error forming device code request. ")
- os.Exit(0)
- }
-
- req.Header.Add("content-type", "application/x-www-form-urlencoded")
- res, err := http.DefaultClient.Do(req)
-
- if err != nil {
- printContactSupportMessage("Error getting device code. ")
- os.Exit(0)
- }
-
- if res.StatusCode == 200 {
- defer res.Body.Close()
-
- parameters := DeviceFlowParameters{}
- err = json.NewDecoder(res.Body).Decode(¶meters)
-
- if err != nil {
- printContactSupportMessage("Error parsing device code response. ")
- os.Exit(0)
- }
-
- return parameters
- } else {
- printContactSupportMessage("Error getting device code. ")
- os.Exit(0)
- return DeviceFlowParameters{}
- }
-}
-
-func printContactSupportMessage(msg string) {
- fmt.Println(msg)
- fmt.Println("Please contact the #support at 'https://discord.qovery.com'. ")
-}
-
-func requestDeviceActivationWith(params DeviceFlowParameters) {
- fmt.Println("Please, open browser @ " + params.VerificationUri + " using any device and enter " + params.UserCode + " code. ")
-}
-
-func getTokensWith(params DeviceFlowParameters) (TokensResponse, error) {
- endpoint := "https://auth.qovery.com/oauth/token"
- payload := strings.NewReader("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=" + params.DeviceCode + "&client_id=" + oAuthUrlParamValueHeadlessClient)
- req, err := http.NewRequest("POST", endpoint, payload)
-
- if err != nil {
- printContactSupportMessage("Error forming get access token request. ")
- os.Exit(0)
- }
-
- req.Header.Add("content-type", "application/x-www-form-urlencoded")
- res, err := http.DefaultClient.Do(req)
-
- if err != nil {
- printContactSupportMessage("Error pooling access token. ")
- os.Exit(0)
- }
-
- defer res.Body.Close()
-
- if res.StatusCode == 200 {
- tokens := TokensResponse{}
- err = json.NewDecoder(res.Body).Decode(&tokens)
- return tokens, err
- } else {
- return TokensResponse{}, errors.New("Could not fetch tokens")
- }
-}
-
-type DeviceFlowParameters struct {
- DeviceCode string `json:"device_code"`
- UserCode string `json:"user_code"`
- VerificationUri string `json:"verification_uri"`
- VerificationUriComplete string `json:"verification_uri_complete"`
- ExpiresIn int64 `json:"expires_in"`
- Interval int64 `json:"interval"`
+ authCmd.Flags().BoolVarP(&skipVersionCheck, "skipVersionCheck", "", false, "Skip CLI version check during authentication")
}
diff --git a/cmd/auth_status.go b/cmd/auth_status.go
new file mode 100644
index 00000000..8d6763c1
--- /dev/null
+++ b/cmd/auth_status.go
@@ -0,0 +1,122 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var authStatusCmd = &cobra.Command{
+ Use: "status",
+ Short: "Show authentication status without exposing the access token",
+ Long: `Show whether the CLI is currently authenticated, as whom, which organization
+is selected, and when the session expires.
+
+This command never prints the access token or any other secret value, regardless
+of flags. Use it as the safe way to check authentication from scripts, CI, and
+automation, instead of parsing the output of 'qovery auth token':
+
+ qovery auth status >/dev/null 2>&1 && echo authenticated || echo "not authenticated"
+
+Unlike most other commands, this always makes a live call to the Qovery API to
+confirm the token is currently accepted â a token that is present and well-formed
+but has been revoked or expired server-side is reported as not authenticated,
+whether it came from a browser login or from QOVERY_CLI_ACCESS_TOKEN / Q_CLI_ACCESS_TOKEN.
+
+Exit code is 0 when authenticated, 1 otherwise.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(true)
+ if err != nil {
+ printAuthStatus(authStatusOutput{
+ Authenticated: false,
+ APIURL: utils.GetAPIBaseURL(),
+ })
+ os.Exit(1)
+ }
+
+ // GetAccessToken only verifies validity server-side for browser/device-flow
+ // (context-stored) sessions. A token supplied via QOVERY_CLI_ACCESS_TOKEN or
+ // Q_CLI_ACCESS_TOKEN is returned as-is with no server round trip, so it can look
+ // well-formed while already being revoked or expired. Always re-verify here,
+ // regardless of where the token came from, so "authenticated" means "the server
+ // currently accepts this token" rather than "a token-shaped string exists".
+ client := utils.GetQoveryClient(tokenType, token)
+ if _, _, verifyErr := client.OrganizationMainCallsAPI.ListOrganization(context.Background()).Execute(); verifyErr != nil {
+ printAuthStatus(authStatusOutput{
+ Authenticated: false,
+ APIURL: utils.GetAPIBaseURL(),
+ })
+ os.Exit(1)
+ }
+
+ output := authStatusOutput{
+ Authenticated: true,
+ TokenType: string(tokenType),
+ APIURL: utils.GetAPIBaseURL(),
+ }
+
+ // Best-effort: context is only populated for browser/device-flow (Bearer) logins,
+ // not for a raw API token passed via QOVERY_CLI_ACCESS_TOKEN / Q_CLI_ACCESS_TOKEN.
+ if ctx, ctxErr := utils.GetCurrentContext(); ctxErr == nil {
+ if !ctx.AccessTokenExpiration.IsZero() {
+ output.ExpiresAt = ctx.AccessTokenExpiration.UTC().Format("2006-01-02T15:04:05Z")
+ }
+ output.OrganizationId = string(ctx.OrganizationId)
+ output.OrganizationName = string(ctx.OrganizationName)
+ output.User = string(ctx.User)
+ }
+
+ printAuthStatus(output)
+ },
+}
+
+type authStatusOutput struct {
+ Authenticated bool `json:"authenticated"`
+ TokenType string `json:"token_type,omitempty"`
+ ExpiresAt string `json:"expires_at,omitempty"`
+ OrganizationId string `json:"organization_id,omitempty"`
+ OrganizationName string `json:"organization_name,omitempty"`
+ User string `json:"user,omitempty"`
+ APIURL string `json:"api_url"`
+}
+
+func printAuthStatus(output authStatusOutput) {
+ if jsonFlag {
+ jsonBytes, err := json.MarshalIndent(output, "", " ")
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ utils.Println(string(jsonBytes))
+ return
+ }
+
+ if !output.Authenticated {
+ utils.Println("Not authenticated. Run 'qovery auth' to log in.")
+ return
+ }
+
+ utils.Println("Authenticated: yes")
+ utils.Println("Token type: " + output.TokenType)
+ if output.ExpiresAt != "" {
+ utils.Println("Expires at: " + output.ExpiresAt)
+ }
+ if output.OrganizationName != "" {
+ utils.Println("Organization: " + output.OrganizationName + " (" + output.OrganizationId + ")")
+ }
+ if output.User != "" {
+ utils.Println("User: " + output.User)
+ }
+ utils.Println("API URL: " + output.APIURL)
+}
+
+func init() {
+ authCmd.AddCommand(authStatusCmd)
+ authStatusCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/auth_token.go b/cmd/auth_token.go
new file mode 100644
index 00000000..214efe1c
--- /dev/null
+++ b/cmd/auth_token.go
@@ -0,0 +1,116 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var authTokenJsonFlag bool
+var authTokenAuthorizationHeaderFlag bool
+var authTokenPrintFlag bool
+
+var authTokenCmd = &cobra.Command{
+ Use: "token",
+ Short: "Output the current valid access token",
+ Long: `Output the current valid access token (refreshing it if expired).
+
+This command provides a valid access token that can be used to make direct API calls
+to the Qovery API. The token is automatically refreshed if it has expired.
+
+For security reasons, the token is not printed by default. You must explicitly
+use --print or --json to output the token value.
+
+If you only need to check whether the CLI is authenticated (e.g. from a script
+or an automated agent), use 'qovery auth status' instead â it never prints the
+token, even with --json.
+
+Examples:
+ # Print the raw token value
+ qovery auth token --print
+
+ # Use directly in a curl command
+ curl -H "Authorization: Bearer $(qovery auth token --print)" https://api.qovery.com/organization
+
+ # Print the full Authorization header value
+ qovery auth token --print --authorization-header
+
+ # Get structured JSON output with token, type, expiration, and API URL
+ qovery auth token --json
+
+ # Get JSON with the authorization header pre-formatted
+ qovery auth token --json --authorization-header`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ // If neither --print nor --json is set, show help and available flags
+ if !authTokenPrintFlag && !authTokenJsonFlag {
+ _ = cmd.Help()
+ return
+ }
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "Error: "+err.Error())
+ os.Exit(1)
+ }
+
+ if authTokenJsonFlag {
+ printTokenAsJSON(tokenType, token)
+ return
+ }
+
+ if authTokenAuthorizationHeaderFlag {
+ fmt.Print(utils.GetAuthorizationHeaderValue(tokenType, token))
+ return
+ }
+
+ // --print: just the raw token value
+ fmt.Print(string(token))
+ },
+}
+
+type authTokenJSONOutput struct {
+ AccessToken string `json:"access_token,omitempty"`
+ TokenType string `json:"token_type,omitempty"`
+ AuthorizationHeader string `json:"authorization_header,omitempty"`
+ ExpiresAt string `json:"expires_at,omitempty"`
+ APIURL string `json:"api_url"`
+}
+
+func printTokenAsJSON(tokenType utils.AccessTokenType, token utils.AccessToken) {
+ output := authTokenJSONOutput{
+ APIURL: utils.GetAPIBaseURL(),
+ }
+
+ if authTokenAuthorizationHeaderFlag {
+ output.AuthorizationHeader = utils.GetAuthorizationHeaderValue(tokenType, token)
+ } else {
+ output.AccessToken = string(token)
+ output.TokenType = string(tokenType)
+ }
+
+ // Try to get expiration from context (only available for Bearer tokens from context.json)
+ if tokenType == "Bearer" {
+ if ctx, err := utils.GetCurrentContext(); err == nil && !ctx.AccessTokenExpiration.IsZero() {
+ output.ExpiresAt = ctx.AccessTokenExpiration.UTC().Format("2006-01-02T15:04:05Z")
+ }
+ }
+
+ jsonBytes, err := json.MarshalIndent(output, "", " ")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "Error: failed to marshal JSON output: "+err.Error())
+ os.Exit(1)
+ }
+ fmt.Println(string(jsonBytes))
+}
+
+func init() {
+ authCmd.AddCommand(authTokenCmd)
+ authTokenCmd.Flags().BoolVar(&authTokenPrintFlag, "print", false, "Print the raw access token value to stdout")
+ authTokenCmd.Flags().BoolVar(&authTokenJsonFlag, "json", false, "Output as JSON with token, type, expiration, and API URL")
+ authTokenCmd.Flags().BoolVar(&authTokenAuthorizationHeaderFlag, "authorization-header", false, "Output the full Authorization header value (e.g. 'Bearer eyJ...')")
+}
diff --git a/cmd/cluster.go b/cmd/cluster.go
new file mode 100644
index 00000000..c6640979
--- /dev/null
+++ b/cmd/cluster.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var clusterCmd = &cobra.Command{
+ Use: "cluster",
+ Short: "Manage clusters",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(clusterCmd)
+}
diff --git a/cmd/cluster_analysis.go b/cmd/cluster_analysis.go
new file mode 100644
index 00000000..bd942c69
--- /dev/null
+++ b/cmd/cluster_analysis.go
@@ -0,0 +1,118 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ clusterAnalysisClusterId string
+ clusterAnalysisId string
+ clusterAnalysisOutputFormat string
+ clusterAnalysisPrometheusUrl string
+ clusterAnalysisCmdArgs []string
+ clusterAnalysisTargetK8sVersion string
+ clusterAnalysisWatch bool
+ clusterAnalysisNoLogs bool
+ clusterAnalysisJson bool
+)
+
+var clusterAnalysisCmd = &cobra.Command{
+ Use: "analysis",
+ Short: "Run and inspect read-only cluster analyses (e.g. cost recommendations)",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ clusterCmd.AddCommand(clusterAnalysisCmd)
+}
+
+// parseAnalysisOutput maps a CLI --output value to the engine output format.
+func parseAnalysisOutput(s string) (qovery.ClusterAnalysisOutputFormat, error) {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "", "json":
+ return qovery.CLUSTERANALYSISOUTPUTFORMAT_JSON, nil
+ case "table":
+ return qovery.CLUSTERANALYSISOUTPUTFORMAT_TABLE, nil
+ case "csv":
+ return qovery.CLUSTERANALYSISOUTPUTFORMAT_CSV, nil
+ default:
+ return "", fmt.Errorf("invalid output format %q (allowed: table, json, csv)", s)
+ }
+}
+
+// isFinalAnalysisStatus reports whether the analysis reached a terminal state.
+func isFinalAnalysisStatus(status qovery.ClusterAnalysisStatus) bool {
+ switch status {
+ case qovery.CLUSTERANALYSISSTATUS_SUCCEEDED,
+ qovery.CLUSTERANALYSISSTATUS_FAILED,
+ qovery.CLUSTERANALYSISSTATUS_TERMINATED:
+ return true
+ default:
+ return false
+ }
+}
+
+// httpError formats an API error using the response body when available.
+func httpError(res *http.Response, err error) error {
+ if res == nil {
+ return err
+ }
+
+ if res.Body == nil {
+ if err != nil {
+ return fmt.Errorf("status code: %s: %w", res.Status, err)
+ }
+ return fmt.Errorf("status code: %s", res.Status)
+ }
+
+ defer func() { _ = res.Body.Close() }()
+ body, readErr := io.ReadAll(res.Body)
+ if readErr != nil {
+ if err != nil {
+ return fmt.Errorf("status code: %s ; cannot read response body: %w ; original error: %w", res.Status, readErr, err)
+ }
+ return fmt.Errorf("status code: %s ; cannot read response body: %w", res.Status, readErr)
+ }
+
+ if err != nil {
+ return fmt.Errorf("status code: %s ; body: %s ; original error: %w", res.Status, string(body), err)
+ }
+ return fmt.Errorf("status code: %s ; body: %s", res.Status, string(body))
+}
+
+// printAnalysisLogs fetches and prints the persisted report/log lines of an analysis.
+func printAnalysisLogs(client *qovery.APIClient, clusterId string, analysisId string) error {
+ logs, res, err := client.ClustersAPI.ListClusterAnalysisLogs(context.Background(), clusterId, analysisId).Execute()
+ if err != nil {
+ return httpError(res, err)
+ }
+
+ utils.Println(analysisReportFromLogs(logs.GetResults()))
+
+ return nil
+}
+
+func analysisReportFromLogs(logs []qovery.ClusterAnalysisLogResponse) string {
+ lines := make([]string, 0, len(logs))
+ for _, line := range logs {
+ lines = append(lines, line.GetMessage())
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/cmd/cluster_analysis_cost_recommendation.go b/cmd/cluster_analysis_cost_recommendation.go
new file mode 100644
index 00000000..66ce73cf
--- /dev/null
+++ b/cmd/cluster_analysis_cost_recommendation.go
@@ -0,0 +1,70 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var clusterAnalysisCostRecommendationCmd = &cobra.Command{
+ Use: "cost-recommendation",
+ Short: "Start a cluster cost recommendation analysis, optionally wait for completion, then print its report",
+ Example: ` qovery cluster analysis cost-recommendation \
+ -c \
+ --output json \
+ --cmd-arg=--history_duration \
+ --cmd-arg=336 \
+ --cmd-arg=--timeframe_duration \
+ --cmd-arg=2.5 \
+ --cmd-arg=--cpu-request \
+ --cmd-arg=99 \
+ --cmd-arg=--cpu-limit \
+ --cmd-arg=99 \
+ --cmd-arg=--memory-buffer-percentage \
+ --cmd-arg=15 \
+ --cmd-arg=--use-oomkill-data \
+ --cmd-arg=--oom-memory-buffer-percentage \
+ --cmd-arg=25 \
+ --cmd-arg=--allow-hpa`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ request, err := newCostRecommendationRequest()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ runClusterAnalysis(request)
+ },
+}
+
+func newCostRecommendationRequest() (*qovery.ClusterAnalysisRequest, error) {
+ outputFormat, err := parseAnalysisOutput(clusterAnalysisOutputFormat)
+ if err != nil {
+ return nil, err
+ }
+
+ request := qovery.NewClusterAnalysisRequest(qovery.CLUSTERANALYSISKIND_COST_RECOMMENDATION, outputFormat)
+ if clusterAnalysisPrometheusUrl != "" {
+ request.SetPrometheusUrl(clusterAnalysisPrometheusUrl)
+ }
+ if len(clusterAnalysisCmdArgs) > 0 {
+ request.SetCmdArgs(clusterAnalysisCmdArgs)
+ }
+
+ return request, nil
+}
+
+func init() {
+ clusterAnalysisCmd.AddCommand(clusterAnalysisCostRecommendationCmd)
+
+ addClusterAnalysisRunFlags(clusterAnalysisCostRecommendationCmd)
+ clusterAnalysisCostRecommendationCmd.Flags().StringVar(&clusterAnalysisPrometheusUrl, "prometheus-url", "", "Optional Prometheus URL")
+ clusterAnalysisCostRecommendationCmd.Flags().StringArrayVar(&clusterAnalysisCmdArgs, "cmd-arg", nil, "Optional allowlisted command argument. Repeat for each argument, e.g. --cmd-arg=--history_duration --cmd-arg=336")
+}
diff --git a/cmd/cluster_analysis_deprecated_api.go b/cmd/cluster_analysis_deprecated_api.go
new file mode 100644
index 00000000..af0d4011
--- /dev/null
+++ b/cmd/cluster_analysis_deprecated_api.go
@@ -0,0 +1,49 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var clusterAnalysisDeprecatedApiCmd = &cobra.Command{
+ Use: "deprecated-api",
+ Short: "Start a deprecated Kubernetes API analysis, optionally wait for completion, then print its report",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ request, err := newDeprecatedApiRequest()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ runClusterAnalysis(request)
+ },
+}
+
+func newDeprecatedApiRequest() (*qovery.ClusterAnalysisRequest, error) {
+ outputFormat, err := parseAnalysisOutput(clusterAnalysisOutputFormat)
+ if err != nil {
+ return nil, err
+ }
+
+ request := qovery.NewClusterAnalysisRequest(qovery.CLUSTERANALYSISKIND_DEPRECATED_API_CHECK, outputFormat)
+ if clusterAnalysisTargetK8sVersion != "" {
+ request.SetTargetKubernetesVersion(clusterAnalysisTargetK8sVersion)
+ }
+
+ return request, nil
+}
+
+func init() {
+ clusterAnalysisCmd.AddCommand(clusterAnalysisDeprecatedApiCmd)
+
+ addClusterAnalysisRunFlags(clusterAnalysisDeprecatedApiCmd)
+ clusterAnalysisDeprecatedApiCmd.Flags().StringVar(&clusterAnalysisTargetK8sVersion, "target-kubernetes-version", "", "Optional target Kubernetes version")
+}
diff --git a/cmd/cluster_analysis_list.go b/cmd/cluster_analysis_list.go
new file mode 100644
index 00000000..b762c991
--- /dev/null
+++ b/cmd/cluster_analysis_list.go
@@ -0,0 +1,88 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var clusterAnalysisListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List previous analyses for a cluster",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+
+ analyses, res, err := client.ClustersAPI.ListClusterAnalyses(context.Background(), clusterAnalysisClusterId).Execute()
+ if err != nil {
+ utils.PrintlnError(httpError(res, err))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if clusterAnalysisJson {
+ utils.Println(getAnalysisJsonOutput(analyses.GetResults()))
+ return
+ }
+
+ var data [][]string
+ for _, a := range analyses.GetResults() {
+ data = append(data, []string{
+ a.GetId(),
+ string(a.GetKind()),
+ string(a.GetStatus()),
+ a.GetCreatedAt().Format(time.RFC3339),
+ a.GetTriggeredBy(),
+ a.GetErrorMessage(),
+ })
+ }
+
+ err = utils.PrintTable([]string{"Id", "Kind", "Status", "Created At", "Triggered By", "Error"}, data)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getAnalysisJsonOutput(analyses []qovery.ClusterAnalysisResponse) string {
+ var results []interface{}
+ for _, a := range analyses {
+ createdAt := a.GetCreatedAt()
+ updatedAt := a.GetUpdatedAt()
+ results = append(results, map[string]interface{}{
+ "id": a.GetId(),
+ "cluster_id": a.GetClusterId(),
+ "kind": a.GetKind(),
+ "status": a.GetStatus(),
+ "created_at": utils.ToIso8601(&createdAt),
+ "updated_at": utils.ToIso8601(&updatedAt),
+ "triggered_by": a.GetTriggeredBy(),
+ "error": a.GetErrorMessage(),
+ })
+ }
+
+ j, err := json.Marshal(results)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ return string(j)
+}
+
+func init() {
+ clusterAnalysisCmd.AddCommand(clusterAnalysisListCmd)
+ clusterAnalysisListCmd.Flags().StringVarP(&clusterAnalysisClusterId, "cluster-id", "c", "", "Cluster ID")
+ clusterAnalysisListCmd.Flags().BoolVar(&clusterAnalysisJson, "json", false, "JSON output")
+ _ = clusterAnalysisListCmd.MarkFlagRequired("cluster-id")
+}
diff --git a/cmd/cluster_analysis_logs.go b/cmd/cluster_analysis_logs.go
new file mode 100644
index 00000000..903cfbb5
--- /dev/null
+++ b/cmd/cluster_analysis_logs.go
@@ -0,0 +1,67 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var clusterAnalysisLogsCmd = &cobra.Command{
+ Use: "logs",
+ Short: "Print the report/logs of a past cluster analysis",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+
+ logs, res, err := client.ClustersAPI.ListClusterAnalysisLogs(context.Background(), clusterAnalysisClusterId, clusterAnalysisId).Execute()
+ if err != nil {
+ utils.PrintlnError(httpError(res, err))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if clusterAnalysisJson {
+ utils.Println(getAnalysisLogsJsonOutput(logs.GetResults()))
+ return
+ }
+
+ utils.Println(analysisReportFromLogs(logs.GetResults()))
+ },
+}
+
+func getAnalysisLogsJsonOutput(logs []qovery.ClusterAnalysisLogResponse) string {
+ var results []interface{}
+ for _, line := range logs {
+ timestamp := line.GetTimestamp()
+ results = append(results, map[string]interface{}{
+ "timestamp": utils.ToIso8601(×tamp),
+ "level": line.GetLevel(),
+ "message": line.GetMessage(),
+ "line_order": line.GetLineOrder(),
+ })
+ }
+
+ j, err := json.Marshal(results)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ return string(j)
+}
+
+func init() {
+ clusterAnalysisCmd.AddCommand(clusterAnalysisLogsCmd)
+ clusterAnalysisLogsCmd.Flags().StringVarP(&clusterAnalysisClusterId, "cluster-id", "c", "", "Cluster ID")
+ clusterAnalysisLogsCmd.Flags().StringVarP(&clusterAnalysisId, "analysis-id", "a", "", "Analysis ID")
+ clusterAnalysisLogsCmd.Flags().BoolVar(&clusterAnalysisJson, "json", false, "JSON output")
+ _ = clusterAnalysisLogsCmd.MarkFlagRequired("cluster-id")
+ _ = clusterAnalysisLogsCmd.MarkFlagRequired("analysis-id")
+}
diff --git a/cmd/cluster_analysis_runner.go b/cmd/cluster_analysis_runner.go
new file mode 100644
index 00000000..029aff8c
--- /dev/null
+++ b/cmd/cluster_analysis_runner.go
@@ -0,0 +1,86 @@
+package cmd
+
+import (
+ "context"
+ "os"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func runClusterAnalysis(request *qovery.ClusterAnalysisRequest) {
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ ctx := context.Background()
+
+ analysis, res, err := client.ClustersAPI.
+ StartClusterAnalysis(ctx, clusterAnalysisClusterId).
+ ClusterAnalysisRequest(*request).
+ Execute()
+ if err != nil {
+ utils.PrintlnError(httpError(res, err))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ analysisId := analysis.GetId()
+ utils.Println("Analysis " + pterm.FgBlue.Sprintf("%s", analysisId) + " started (" + string(analysis.GetStatus()) + ")")
+
+ if !clusterAnalysisWatch {
+ utils.PrintlnInfo("Run 'qovery cluster analysis logs --cluster-id " + clusterAnalysisClusterId + " --analysis-id " + analysisId + "' to fetch the report once finished.")
+ return
+ }
+
+ lastStatus := analysis.GetStatus()
+ for !isFinalAnalysisStatus(lastStatus) {
+ time.Sleep(5 * time.Second)
+
+ current, res, err := client.ClustersAPI.GetClusterAnalysis(ctx, clusterAnalysisClusterId, analysisId).Execute()
+ if err != nil {
+ utils.PrintlnError(httpError(res, err))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if current.GetStatus() != lastStatus {
+ lastStatus = current.GetStatus()
+ utils.Println("Status: " + string(lastStatus))
+ }
+
+ if isFinalAnalysisStatus(current.GetStatus()) {
+ lastStatus = current.GetStatus()
+ if errMsg := current.GetErrorMessage(); errMsg != "" {
+ utils.Println(pterm.Error.Sprintf("%s", errMsg))
+ }
+ break
+ }
+ }
+
+ if !clusterAnalysisNoLogs {
+ if err := printAnalysisLogs(client, clusterAnalysisClusterId, analysisId); err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ if lastStatus != qovery.CLUSTERANALYSISSTATUS_SUCCEEDED {
+ utils.Println(pterm.Error.Sprintf("Analysis %s ended with status %s", analysisId, string(lastStatus)))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(pterm.FgGreen.Sprintf("Analysis %s succeeded", analysisId))
+}
+
+func addClusterAnalysisRunFlags(cmd *cobra.Command) {
+ cmd.Flags().StringVarP(&clusterAnalysisClusterId, "cluster-id", "c", "", "Cluster ID")
+ cmd.Flags().StringVar(&clusterAnalysisOutputFormat, "output", "json", "Report output: table, json, csv")
+ cmd.Flags().BoolVar(&clusterAnalysisWatch, "watch", true, "Wait for the analysis to finish and print its report")
+ cmd.Flags().BoolVar(&clusterAnalysisNoLogs, "no-logs", false, "Do not print the report logs when finished")
+ _ = cmd.MarkFlagRequired("cluster-id")
+}
diff --git a/cmd/cluster_analysis_test.go b/cmd/cluster_analysis_test.go
new file mode 100644
index 00000000..b5d01ae3
--- /dev/null
+++ b/cmd/cluster_analysis_test.go
@@ -0,0 +1,88 @@
+package cmd
+
+import (
+ "testing"
+
+ "github.com/qovery/qovery-client-go"
+)
+
+func TestParseAnalysisOutput(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected qovery.ClusterAnalysisOutputFormat
+ wantErr bool
+ }{
+ {
+ name: "empty defaults to json",
+ input: "",
+ expected: qovery.CLUSTERANALYSISOUTPUTFORMAT_JSON,
+ },
+ {
+ name: "json",
+ input: "json",
+ expected: qovery.CLUSTERANALYSISOUTPUTFORMAT_JSON,
+ },
+ {
+ name: "table",
+ input: "table",
+ expected: qovery.CLUSTERANALYSISOUTPUTFORMAT_TABLE,
+ },
+ {
+ name: "csv",
+ input: "csv",
+ expected: qovery.CLUSTERANALYSISOUTPUTFORMAT_CSV,
+ },
+ {
+ name: "trims and ignores case",
+ input: " CSV ",
+ expected: qovery.CLUSTERANALYSISOUTPUTFORMAT_CSV,
+ },
+ {
+ name: "invalid format",
+ input: "yaml",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := parseAnalysisOutput(tt.input)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("expected an error")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if got != tt.expected {
+ t.Fatalf("expected %q, got %q", tt.expected, got)
+ }
+ })
+ }
+}
+
+func TestIsFinalAnalysisStatus(t *testing.T) {
+ tests := []struct {
+ status qovery.ClusterAnalysisStatus
+ expected bool
+ }{
+ {status: qovery.CLUSTERANALYSISSTATUS_PENDING, expected: false},
+ {status: qovery.CLUSTERANALYSISSTATUS_RUNNING, expected: false},
+ {status: qovery.CLUSTERANALYSISSTATUS_SUCCEEDED, expected: true},
+ {status: qovery.CLUSTERANALYSISSTATUS_FAILED, expected: true},
+ {status: qovery.CLUSTERANALYSISSTATUS_TERMINATED, expected: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(string(tt.status), func(t *testing.T) {
+ got := isFinalAnalysisStatus(tt.status)
+ if got != tt.expected {
+ t.Fatalf("expected %t, got %t", tt.expected, got)
+ }
+ })
+ }
+}
diff --git a/cmd/cluster_debug_pod.go b/cmd/cluster_debug_pod.go
new file mode 100644
index 00000000..c97b53fb
--- /dev/null
+++ b/cmd/cluster_debug_pod.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/pkg"
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var clusterDebugPodCmd = &cobra.Command{
+ Use: "debug-pod",
+ Short: "Launch a debug pod and attach to it",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ if organizationId == "" {
+ organizationId, err = usercontext.GetOrganizationContextResourceId(client, organizationName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ flavor := "REGULAR_PRIVILEGE"
+ if fullPriviledge {
+ flavor = "FULL_PRIVILEGE"
+ }
+ request := DebugPodRequest{
+ utils.Id(organizationId),
+ utils.Id(clusterId),
+ 0,
+ 0,
+ flavor,
+ nodeSelector,
+ }
+
+ pkg.ExecShell(&request, "/shell/debug")
+ },
+}
+
+type DebugPodRequest struct {
+ OrganizationID utils.Id `url:"organization"`
+ ClusterID utils.Id `url:"cluster"`
+ TtyWidth uint16 `url:"tty_width"`
+ TtyHeight uint16 `url:"tty_height"`
+ Flavor string `url:"flavor"`
+ NodeSelector string `url:"node_selector,omitempty"`
+}
+
+func (s *DebugPodRequest) SetTtySize(width uint16, height uint16) {
+ s.TtyWidth = width
+ s.TtyHeight = height
+}
+
+var fullPriviledge bool
+var nodeSelector string
+
+func init() {
+ clusterCmd.AddCommand(clusterDebugPodCmd)
+ clusterDebugPodCmd.Flags().StringVarP(&organizationId, "organization-id", "o", "", "Organization ID")
+ clusterDebugPodCmd.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "Cluster ID")
+ clusterDebugPodCmd.Flags().StringVarP(&nodeSelector, "node-selector", "n", "", "Specify a node selector for the debug pod to be started on")
+ clusterDebugPodCmd.Flags().BoolVarP(&fullPriviledge, "full-privilege", "p", false, "Start a full privileged debug pod which has access to host machine. ")
+ _ = clusterDebugPodCmd.MarkFlagRequired("cluster-id")
+}
diff --git a/cmd/cluster_deploy.go b/cmd/cluster_deploy.go
new file mode 100644
index 00000000..9c1fd608
--- /dev/null
+++ b/cmd/cluster_deploy.go
@@ -0,0 +1,44 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg/cluster"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var clusterDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy a cluster",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ err = cluster.NewClusterService(client, &promptuifactory.PromptUiFactoryImpl{}).DeployCluster(organizationName, clusterName, watchFlag)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func init() {
+ clusterCmd.AddCommand(clusterDeployCmd)
+ clusterDeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ clusterDeployCmd.Flags().StringVarP(&clusterName, "cluster", "n", "", "Cluster Name")
+ clusterDeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cluster status until it's ready or an error occurs")
+
+ _ = clusterDeployCmd.MarkFlagRequired("cluster")
+}
diff --git a/cmd/cluster_get_token.go b/cmd/cluster_get_token.go
new file mode 100644
index 00000000..4d427a09
--- /dev/null
+++ b/cmd/cluster_get_token.go
@@ -0,0 +1,36 @@
+package cmd
+
+import (
+ "fmt"
+ "github.com/qovery/qovery-cli/pkg"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var getTokenReadOnly bool
+
+var getTokenCommand = &cobra.Command{
+ Use: "get-token",
+ Short: "Get token for a cluster ID",
+ Run: func(cmd *cobra.Command, args []string) {
+ validateGetTokenFlags()
+ getToken(getTokenReadOnly)
+ },
+}
+
+func init() {
+ getTokenCommand.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "Cluster ID")
+ getTokenCommand.Flags().BoolVarP(&getTokenReadOnly, "read-only", "r", false, "Get a read-only service account token instead of an admin token")
+ clusterCmd.AddCommand(getTokenCommand)
+}
+
+func validateGetTokenFlags() {
+ if clusterId == "" {
+ utils.PrintlnError(fmt.Errorf("cluster ID is required (--cluster-id)"))
+ }
+}
+
+func getToken(readOnly bool) {
+ response := pkg.GetTokenByClusterId(clusterId, readOnly)
+ utils.Println(response)
+}
diff --git a/cmd/cluster_install.go b/cmd/cluster_install.go
new file mode 100644
index 00000000..af1fcc36
--- /dev/null
+++ b/cmd/cluster_install.go
@@ -0,0 +1,93 @@
+package cmd
+
+import (
+ "fmt"
+ "github.com/spf13/cobra"
+ "os"
+ "path/filepath"
+
+ "github.com/qovery/qovery-cli/pkg/cluster"
+ "github.com/qovery/qovery-cli/pkg/cluster/containerregistry"
+ "github.com/qovery/qovery-cli/pkg/cluster/credentials"
+ "github.com/qovery/qovery-cli/pkg/cluster/selfmanaged"
+ "github.com/qovery/qovery-cli/pkg/filewriter"
+ "github.com/qovery/qovery-cli/pkg/organization"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var clusterInstallCmd = &cobra.Command{
+ Use: "install",
+ Short: "Install Qovery on your cluster.",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ clusterInstallBaseValuesFile, err = validateClusterInstallBaseValuesFile(clusterInstallBaseValuesFile)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ var promptUiFactory promptuifactory.PromptUiFactory = &promptuifactory.PromptUiFactoryImpl{}
+ var organizationService = organization.NewOrganizationService(client, promptUiFactory)
+ var clusterService = cluster.NewClusterService(client, promptUiFactory)
+ var clusterCredentialsService = credentials.NewClusterCredentialsService(client, promptUiFactory)
+ var containerRegistryService = containerregistry.NewClusterContainerRegistryService(client, promptUiFactory)
+ var selfManagedService = selfmanaged.NewSelfManagedClusterService(client, clusterService, clusterCredentialsService, containerRegistryService, promptUiFactory, clusterInstallBaseValuesFile)
+ var fileWriterService filewriter.FileWriterService = filewriter.NewFileWriterService()
+ var service = selfmanaged.NewInstallSelfManagedClusterService(organizationService, selfManagedService, clusterService, fileWriterService, promptUiFactory)
+
+ // when
+ informationMessage, err := service.InstallCluster()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ if informationMessage != nil {
+ utils.Println(fmt.Sprintf("%s\n", *informationMessage))
+ os.Exit(0)
+ }
+ },
+}
+
+var clusterInstallBaseValuesFile string
+
+func validateClusterInstallBaseValuesFile(path string) (string, error) {
+ if path == "" {
+ return "", nil
+ }
+
+ expandedPath, err := expandPath(path)
+ if err != nil {
+ return "", fmt.Errorf("expand base values file path: %w", err)
+ }
+
+ absPath, err := filepath.Abs(expandedPath)
+ if err != nil {
+ return "", fmt.Errorf("resolve base values file path: %w", err)
+ }
+
+ fileInfo, err := os.Stat(absPath)
+ if err != nil {
+ return "", fmt.Errorf("read base values file path %q: %w", absPath, err)
+ }
+ if !fileInfo.Mode().IsRegular() {
+ return "", fmt.Errorf("base values file path %q must be a file", absPath)
+ }
+
+ return absPath, nil
+}
+
+func init() {
+ clusterInstallCmd.Flags().StringVar(&clusterInstallBaseValuesFile, "base-values-file", "", "Local Helm values base file to use instead of downloading values-demo-.yaml from qovery-chart")
+ clusterCmd.AddCommand(clusterInstallCmd)
+}
diff --git a/cmd/cluster_install_test.go b/cmd/cluster_install_test.go
new file mode 100644
index 00000000..cfd2afa6
--- /dev/null
+++ b/cmd/cluster_install_test.go
@@ -0,0 +1,31 @@
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestValidateClusterInstallBaseValuesFile(t *testing.T) {
+ t.Run("accepts an existing file", func(t *testing.T) {
+ baseValuesFile := filepath.Join(t.TempDir(), "values-scaleway.yaml")
+ if err := os.WriteFile(baseValuesFile, []byte("services: {}\n"), 0o600); err != nil {
+ t.Fatalf("create base values file: %v", err)
+ }
+
+ validatedPath, err := validateClusterInstallBaseValuesFile(baseValuesFile)
+ if err != nil {
+ t.Fatalf("validate base values file: %v", err)
+ }
+ if validatedPath != baseValuesFile {
+ t.Fatalf("expected %q, got %q", baseValuesFile, validatedPath)
+ }
+ })
+
+ t.Run("rejects a directory", func(t *testing.T) {
+ _, err := validateClusterInstallBaseValuesFile(t.TempDir())
+ if err == nil {
+ t.Fatal("expected an error for a directory path")
+ }
+ })
+}
diff --git a/cmd/cluster_kubeconfig.go b/cmd/cluster_kubeconfig.go
new file mode 100644
index 00000000..f1b5f2f4
--- /dev/null
+++ b/cmd/cluster_kubeconfig.go
@@ -0,0 +1,66 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/qovery/qovery-cli/pkg"
+
+ "github.com/qovery/qovery-cli/utils"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var readOnlyKubeconfig bool
+
+var downloadKubeconfigCmd = &cobra.Command{
+ Use: "kubeconfig",
+ Short: "Retrieve kubeconfig with a cluster ID",
+ Run: func(cmd *cobra.Command, args []string) {
+ validateKubeconfigFlags()
+ kubeconfigFilename := downloadKubeconfig(clusterId, readOnlyKubeconfig)
+ log.Info("Kubeconfig file created in the current directory.")
+ log.Info("Execute `export KUBECONFIG=" + kubeconfigFilename + "` to use it.")
+ if readOnlyKubeconfig {
+ log.Info("This kubeconfig uses read-only access (ServiceAccount with view ClusterRole).")
+ }
+ },
+}
+
+func init() {
+ downloadKubeconfigCmd.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "Cluster ID")
+ downloadKubeconfigCmd.Flags().BoolVarP(&readOnlyKubeconfig, "read-only", "r", false, "Download a read-only kubeconfig backed by a Kubernetes service account with the view ClusterRole")
+ clusterCmd.AddCommand(downloadKubeconfigCmd)
+}
+
+func validateKubeconfigFlags() {
+ if clusterId == "" {
+ utils.PrintlnError(fmt.Errorf("cluster ID is required (--cluster-id)"))
+ os.Exit(1)
+ }
+}
+
+func downloadKubeconfig(clusterId string, readOnly bool) string {
+ kubeconfig := pkg.GetKubeconfigByClusterId(clusterId, readOnly)
+
+ dir, err := os.Getwd()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ suffix := ""
+ if readOnly {
+ suffix = "-readonly"
+ }
+ kubeconfigFilename := filepath.Join(dir, "kubeconfig"+suffix+"-"+clusterId+".yaml")
+ writeError := os.WriteFile(kubeconfigFilename, []byte(kubeconfig), 0600)
+ if writeError != nil {
+ utils.PrintlnError(writeError)
+ os.Exit(1)
+ }
+
+ return kubeconfigFilename
+}
diff --git a/cmd/cluster_list.go b/cmd/cluster_list.go
new file mode 100644
index 00000000..e84e7e34
--- /dev/null
+++ b/cmd/cluster_list.go
@@ -0,0 +1,94 @@
+package cmd
+
+import (
+ "encoding/json"
+ "github.com/qovery/qovery-client-go"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg/cluster"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var clusterListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List clusters",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ clusters, err := cluster.NewClusterService(client, &promptuifactory.PromptUiFactoryImpl{}).ListClusters(organizationId)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(getClusterJsonOutput(clusters.GetResults()))
+ return
+ }
+
+ var data [][]string
+ for _, cluster := range clusters.GetResults() {
+ data = append(data, []string{cluster.Id, cluster.Name, "cluster",
+ utils.GetClusterStatusTextWithColor(*cluster.Status), cluster.UpdatedAt.String()})
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name", "Type", "Status", "Last Update"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getClusterJsonOutput(clusters []qovery.Cluster) string {
+ var results []interface{}
+
+ for _, cluster := range clusters {
+ results = append(results, map[string]interface{}{
+ "id": cluster.Id,
+ "updated_at": utils.ToIso8601(cluster.UpdatedAt),
+ "type": "cluster",
+ "name": cluster.Name,
+ "status": cluster.Status,
+ })
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ clusterCmd.AddCommand(clusterListCmd)
+ clusterListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ clusterListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/cluster_lock.go b/cmd/cluster_lock.go
new file mode 100644
index 00000000..5c6b80cb
--- /dev/null
+++ b/cmd/cluster_lock.go
@@ -0,0 +1,80 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+ "io"
+ "os"
+)
+
+var clusterLockCmd = &cobra.Command{
+ Use: "lock",
+ Short: "Lock a cluster",
+ Run: func(cmd *cobra.Command, args []string) {
+ lockCluster()
+ },
+}
+
+func init() {
+ clusterLockCmd.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "Cluster ID")
+ clusterLockCmd.Flags().StringVarP(&lockReason, "reason", "r", "", "Reason")
+ clusterLockCmd.Flags().Int32VarP(&lockTtlInDays, "ttl-in-days", "d", -1, " Time-to-live (TTL) for the lock in days (1 to 5 days)")
+
+ _ = clusterLockCmd.MarkFlagRequired("cluster-id")
+ _ = clusterLockCmd.MarkFlagRequired("reason")
+
+ clusterCmd.AddCommand(clusterLockCmd)
+}
+
+func lockCluster() {
+ var ttlInDays *int32 = nil
+ if lockTtlInDays != -1 {
+ ttlInDays = &lockTtlInDays
+ }
+
+ if utils.Validate("lock") {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ lockClusterRequest := qovery.ClusterLockRequest{
+ Reason: lockReason,
+ TtlInDays: ttlInDays,
+ }
+
+ _, http, err := client.ClustersAPI.LockCluster(context.Background(), clusterId).ClusterLockRequest(lockClusterRequest).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ result, _ := io.ReadAll(http.Body)
+ LogDetail(result)
+ os.Exit(1)
+ }
+
+ fmt.Println("Cluster locked.")
+ }
+}
+
+func LogDetail(result []byte) {
+ var response struct {
+ Detail string `json:"detail"`
+ }
+
+ if err := json.Unmarshal(result, &response); err != nil {
+ log.Error("", result)
+ } else {
+ if response.Detail != "" {
+ log.Error("Error detail: ", response.Detail)
+ } else {
+ log.Error("", result)
+ }
+ }
+}
diff --git a/cmd/cluster_locked.go b/cmd/cluster_locked.go
new file mode 100644
index 00000000..23e80e58
--- /dev/null
+++ b/cmd/cluster_locked.go
@@ -0,0 +1,70 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+ "text/tabwriter"
+ "time"
+
+ "github.com/qovery/qovery-cli/utils"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var clusterLockedCmd = &cobra.Command{
+ Use: "locked",
+ Short: "List locked clusters",
+ Run: func(cmd *cobra.Command, args []string) {
+ clusterLocked()
+ },
+}
+
+func init() {
+ clusterLockedCmd.Flags().StringVarP(&organizationId, "organization-id", "o", "", "Organization ID")
+ _ = clusterLockedCmd.MarkFlagRequired("organization-id")
+
+ clusterCmd.AddCommand(clusterLockedCmd)
+}
+
+func clusterLocked() {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ lockedClusters, res, err := client.OrganizationClusterLockAPI.ListClusterLock(context.Background(), organizationId).Execute()
+ if res != nil && res.StatusCode != http.StatusOK {
+ result, _ := io.ReadAll(res.Body)
+ log.Errorf("Could not list locked clusters : %s. %s", res.Status, string(result))
+ return
+ }
+
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
+ format := "%s\t | %s\t | %s\t | %s\t | %s\t | %s\n"
+ if _, err := fmt.Fprintf(w, format, "", "cluster_id", "locked_at", "ttl_in_days", "locked_by", "reason"); err != nil {
+ log.Fatal(err)
+ }
+ for idx, lock := range lockedClusters.Results {
+ ttlInDays := "infinite"
+ if lock.TtlInDays != nil {
+ ttlInDays = strconv.Itoa(int(*lock.TtlInDays))
+ }
+
+ if _, err := fmt.Fprintf(w, format, fmt.Sprintf("%d", idx+1), lock.ClusterId, lock.LockedAt.Format(time.RFC1123), ttlInDays, lock.OwnerName, lock.Reason); err != nil {
+ log.Fatal(err)
+ }
+ }
+ if err := w.Flush(); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/cluster_nodes.go b/cmd/cluster_nodes.go
new file mode 100644
index 00000000..f89f3852
--- /dev/null
+++ b/cmd/cluster_nodes.go
@@ -0,0 +1,127 @@
+package cmd
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/appscode/go-querystring/query"
+ "github.com/gorilla/websocket"
+ "net/http"
+ "net/url"
+ "os"
+ "regexp"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var clusterListNodesCmd = &cobra.Command{
+ Use: "list-nodes",
+ Short: "List cluster nodes",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ request := ListNodesRequest{
+ utils.Id(organizationId),
+ utils.Id(clusterId),
+ }
+
+ nodes, err := ExecListNodes(&request)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ var data [][]string
+ for _, node := range nodes.Nodes {
+ data = append(data, []string{node.Name})
+ }
+
+ err = utils.PrintTable([]string{"Name"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+type ListNodesRequest struct {
+ OrganizationID utils.Id `url:"organization"`
+ ClusterID utils.Id `url:"cluster"`
+}
+type NodeResponse struct {
+ Name string
+}
+type ListNodeResponse struct {
+ Nodes []NodeResponse
+}
+
+func ExecListNodes(req *ListNodesRequest) (*ListNodeResponse, error) {
+ command, err := query.Values(req)
+ if err != nil {
+ return nil, err
+ }
+
+ wsURL, err := url.Parse(fmt.Sprintf("%s/cluster/nodes", utils.WebsocketUrl()))
+ if err != nil {
+ return nil, err
+ }
+ pattern := regexp.MustCompile("%5B([0-9]+)%5D=")
+ wsURL.RawQuery = pattern.ReplaceAllString(command.Encode(), "[${1}]=")
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ headers := http.Header{"Authorization": {utils.GetAuthorizationHeaderValue(tokenType, token)}}
+ wsConn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), headers)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ _ = wsConn.Close()
+ }()
+
+ msgType, payload, err := wsConn.ReadMessage()
+ if err != nil {
+ return nil, err
+ }
+
+ switch msgType {
+ case websocket.TextMessage:
+ var data ListNodeResponse
+ err = json.Unmarshal(payload, &data)
+ if err != nil {
+ return nil, err
+ }
+ return &data, nil
+ default:
+ return nil, errors.New("received invalid message while listing pods: " + string(rune(msgType)) + " " + string(payload))
+ }
+}
+
+func init() {
+ clusterCmd.AddCommand(clusterListNodesCmd)
+ clusterListNodesCmd.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "Cluster ID")
+}
diff --git a/cmd/cluster_stop.go b/cmd/cluster_stop.go
new file mode 100644
index 00000000..16d1c14e
--- /dev/null
+++ b/cmd/cluster_stop.go
@@ -0,0 +1,43 @@
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/pkg/cluster"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var clusterStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop a cluster",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ err = cluster.NewClusterService(client, &promptuifactory.PromptUiFactoryImpl{}).StopCluster(organizationName, clusterName, watchFlag)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func init() {
+ clusterCmd.AddCommand(clusterStopCmd)
+ clusterStopCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ clusterStopCmd.Flags().StringVarP(&clusterName, "cluster", "n", "", "Cluster Name")
+ clusterStopCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cluster status until it's ready or an error occurs")
+
+ _ = clusterStopCmd.MarkFlagRequired("cluster")
+}
diff --git a/cmd/cluster_unlock.go b/cmd/cluster_unlock.go
new file mode 100644
index 00000000..a49b2f36
--- /dev/null
+++ b/cmd/cluster_unlock.go
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "io"
+ "os"
+)
+
+var clusterUnlockCmd = &cobra.Command{
+ Use: "unlock",
+ Short: "Unlock a cluster",
+ Run: func(cmd *cobra.Command, args []string) {
+ unlockCluster()
+ },
+}
+
+func init() {
+ clusterUnlockCmd.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "Cluster ID")
+ _ = clusterLockCmd.MarkFlagRequired("cluster-id")
+
+ clusterCmd.AddCommand(clusterUnlockCmd)
+}
+
+func unlockCluster() {
+ if utils.Validate("unlock") {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ http, err := client.ClustersAPI.UnlockCluster(context.Background(), clusterId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ result, _ := io.ReadAll(http.Body)
+ LogDetail(result)
+ os.Exit(1)
+ }
+ fmt.Println("Cluster unlocked.")
+ }
+}
diff --git a/cmd/cluster_upgrade_to_next_kubernetes_version.go b/cmd/cluster_upgrade_to_next_kubernetes_version.go
new file mode 100644
index 00000000..f7876f7a
--- /dev/null
+++ b/cmd/cluster_upgrade_to_next_kubernetes_version.go
@@ -0,0 +1,139 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/manifoldco/promptui"
+ "github.com/pkg/errors"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var clusterUpgradeCmd = &cobra.Command{
+ Use: "upgrade",
+ Short: "Upgrade a cluster to next kubernetes version available for the cluster",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ orgId, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ clusters, _, err := client.ClustersAPI.ListOrganizationCluster(context.Background(), orgId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cluster := utils.FindByClusterName(clusters.GetResults(), clusterName)
+
+ if cluster == nil {
+ utils.PrintlnError(fmt.Errorf("cluster %s not found", clusterName))
+ utils.PrintlnInfo("You can list all clusters with: qovery cluster list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ status, _, err := client.ClustersAPI.GetClusterStatus(context.Background(), orgId, cluster.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ if status.NextK8sAvailableVersion.Get() == nil {
+ utils.PrintlnError(fmt.Errorf("no available kubernetes version to upgrade to for this cluster"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("A new kubernetes version `%s` is available for your cluster %s." /**status.NextK8sAvailableVersion.Get()*/, "", clusterName))
+ if !proceedWithoutConfirmation {
+ prompt := promptui.Select{
+ Label: "Do you want to proceed with cluster upgrade? [Yes/No]",
+ Items: []string{"Yes", "No"},
+ }
+ _, upgradePromptResult, err := prompt.Run()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ if strings.ToLower(strings.Trim(upgradePromptResult, " ")) != "yes" {
+ utils.Println("Cluster upgrade aborted")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ } else {
+ utils.Println("Skipping confirmation, proceeding with cluster upgrade..")
+ }
+
+ _, res, err := client.ClustersAPI.UpgradeCluster(context.Background(), cluster.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+
+ // print http body error message
+ if res.StatusCode != 200 {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ }
+
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if watchFlag {
+ for {
+ status, _, err := client.ClustersAPI.GetClusterStatus(context.Background(), orgId, cluster.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ }
+
+ if utils.IsTerminalClusterState(status.Status) {
+ break
+ }
+
+ utils.Println(fmt.Sprintf("Cluster status: %s", utils.GetClusterStatusTextWithColor(status.GetStatus())))
+
+ // sleep here to avoid too many requests
+ time.Sleep(5 * time.Second)
+ }
+
+ utils.Println(fmt.Sprintf("Cluster %s upgraded!", pterm.FgBlue.Sprintf("%s", clusterName)))
+ } else {
+ utils.Println(fmt.Sprintf("Upgrading cluster %s in progress..", pterm.FgBlue.Sprintf("%s", clusterName)))
+ }
+ },
+}
+
+var proceedWithoutConfirmation bool = false
+
+func init() {
+ clusterCmd.AddCommand(clusterUpgradeCmd)
+ clusterUpgradeCmd.Flags().BoolVarP(&proceedWithoutConfirmation, "skip-confirmation", "y", false, "Skip prompt confirmation if passed")
+ clusterUpgradeCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ clusterUpgradeCmd.Flags().StringVarP(&clusterName, "cluster", "n", "", "Cluster Name")
+ clusterUpgradeCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cluster status until it's ready or an error occurs")
+
+ _ = clusterUpgradeCmd.MarkFlagRequired("cluster")
+}
diff --git a/cmd/commands_list.go b/cmd/commands_list.go
new file mode 100644
index 00000000..3839d22c
--- /dev/null
+++ b/cmd/commands_list.go
@@ -0,0 +1,58 @@
+package cmd
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+var listCmd = &cobra.Command{
+ Use: "list-commands",
+ Short: "List all available commands with descriptions, aliases, args, and flags",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println("Available commands:")
+ printCommandsRecursive(rootCmd, "")
+ },
+}
+
+func printCommandsRecursive(cmd *cobra.Command, parentPath string) {
+ for _, c := range cmd.Commands() {
+ if c.Hidden {
+ continue
+ }
+
+ fullCmd := strings.TrimSpace(parentPath + " " + c.Name())
+ aliases := ""
+ if len(c.Aliases) > 0 {
+ aliases = fmt.Sprintf(" (aliases: %s)", strings.Join(c.Aliases, ", "))
+ }
+
+ fmt.Printf(" %s: %s%s\n", fullCmd, c.Short, aliases)
+
+ if c.Use != c.Name() {
+ fmt.Printf(" Usage: %s\n", c.UseLine())
+ }
+
+ c.LocalFlags().VisitAll(func(f *pflag.Flag) {
+ defVal := f.DefValue
+ if f.Value.Type() == "string" && defVal == "" {
+ defVal = `""`
+ }
+
+ short := ""
+ if f.Shorthand != "" {
+ short = fmt.Sprintf("-%s, ", f.Shorthand)
+ }
+
+ fmt.Printf(" Flag: %s--%s (%s), default: %s\n", short, f.Name, f.Value.Type(), defVal)
+ })
+
+ printCommandsRecursive(c, fullCmd)
+ }
+}
+
+func init() {
+ rootCmd.AddCommand(listCmd)
+}
diff --git a/cmd/console.go b/cmd/console.go
index cc2f6bb4..655c38d6 100644
--- a/cmd/console.go
+++ b/cmd/console.go
@@ -13,27 +13,30 @@ var consoleCmd = &cobra.Command{
Short: "Opens the application in Qovery Console in your browser",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)
- organization, _, err := utils.CurrentOrganization()
+ organization, _, err := utils.CurrentOrganization(true)
if err != nil {
utils.PrintlnError(err)
os.Exit(0)
}
- project, _, err := utils.CurrentProject()
+
+ project, _, err := utils.CurrentProject(true)
if err != nil {
utils.PrintlnError(err)
os.Exit(0)
}
- environment, _, err := utils.CurrentEnvironment()
+
+ environment, _, err := utils.CurrentEnvironment(true)
if err != nil {
utils.PrintlnError(err)
os.Exit(0)
}
- application, _, err := utils.CurrentApplication()
+ service, err := utils.CurrentService(true)
if err != nil {
utils.PrintlnError(err)
os.Exit(0)
}
- url := fmt.Sprintf("https://console.qovery.com/platform/organization/%v/projects/%v/environments/%v/applications/%v/summary", organization, project, environment, application)
+
+ url := fmt.Sprintf("https://console.qovery.com/organization/%v/project/%v/environment/%v/%v/%v/general", organization, project, environment, service.Type, service.ID)
utils.PrintlnInfo("Opening " + url)
err = browser.OpenURL(url)
if err != nil {
diff --git a/cmd/container.go b/cmd/container.go
new file mode 100644
index 00000000..704cd104
--- /dev/null
+++ b/cmd/container.go
@@ -0,0 +1,31 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var containerName string
+var containerNames string
+var containerImageName string
+var containerTag string
+var targetContainerName string
+var containerCustomDomain string
+
+var containerCmd = &cobra.Command{
+ Use: "container",
+ Short: "Manage containers",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(containerCmd)
+}
diff --git a/cmd/container_cancel.go b/cmd/container_cancel.go
new file mode 100644
index 00000000..1795ac16
--- /dev/null
+++ b/cmd/container_cancel.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerCancelCmd = &cobra.Command{
+ Use: "cancel",
+ Short: "Cancel a container deployment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ msg, err := utils.CancelServiceDeployment(client, envId, container.Id, utils.ContainerType, watchFlag)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if msg != "" {
+ utils.PrintlnInfo(msg)
+ return
+ }
+
+ utils.Println(fmt.Sprintf("Container %s deployment cancelled!", pterm.FgBlue.Sprintf("%s", containerName)))
+ },
+}
+
+func init() {
+ containerCmd.AddCommand(containerCancelCmd)
+ containerCancelCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerCancelCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerCancelCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerCancelCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerCancelCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cancel until it's done or an error occurs")
+
+ _ = containerCancelCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_clone.go b/cmd/container_clone.go
new file mode 100644
index 00000000..159c8a18
--- /dev/null
+++ b/cmd/container_clone.go
@@ -0,0 +1,116 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/go-errors/errors"
+ "github.com/pterm/pterm"
+ "io"
+ "os"
+
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerCloneCmd = &cobra.Command{
+ Use: "clone",
+ Short: "Clone a container",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container, err := getContainerContextResource(client, containerName, envId)
+
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ targetProjectId := projectId // use same project as the source project
+ if targetProjectName != "" {
+
+ targetProjectId, err = getProjectContextResourceId(client, targetProjectName, organizationId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ targetEnvironmentId := envId // use same env as the source env
+ if targetEnvironmentName != "" {
+
+ targetEnvironmentId, err = getEnvironmentContextResourceId(client, targetEnvironmentName, targetProjectId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ if targetContainerName == "" {
+ // use same container name as the source container
+ targetContainerName = container.Name
+ }
+
+ req := qovery.CloneServiceRequest{
+ Name: targetContainerName,
+ EnvironmentId: targetEnvironmentId,
+ }
+
+ clonedService, res, err := client.ContainersAPI.CloneContainer(context.Background(), container.Id).CloneServiceRequest(req).Execute()
+
+ if err != nil {
+ // print http body error message
+ if res.StatusCode != 200 {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ }
+
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ name := ""
+ if clonedService != nil {
+ name = clonedService.Name
+ }
+
+ utils.Println(fmt.Sprintf("Container %s cloned!", pterm.FgBlue.Sprintf("%s", name)))
+ },
+}
+
+func init() {
+ containerCmd.AddCommand(containerCloneCmd)
+ containerCloneCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerCloneCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerCloneCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerCloneCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerCloneCmd.Flags().StringVarP(&targetProjectName, "target-project", "", "", "Target Project Name")
+ containerCloneCmd.Flags().StringVarP(&targetEnvironmentName, "target-environment", "", "", "Target Environment Name")
+ containerCloneCmd.Flags().StringVarP(&targetContainerName, "target-container-name", "", "", "Target Container Name")
+
+ _ = containerCloneCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_create.go b/cmd/container_create.go
new file mode 100644
index 00000000..34faeaf2
--- /dev/null
+++ b/cmd/container_create.go
@@ -0,0 +1,121 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+
+ "github.com/pkg/errors"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var containerRegistryId string
+var containerPort int32
+var containerCpu int32
+var containerMemory int32
+var containerMinRunningInstances int32
+var containerMaxRunningInstances int32
+
+var containerCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create a container service",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ utils.CheckError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ utils.CheckError(err)
+
+ var ports []qovery.ServicePortRequestPortsInner
+ if containerPort > 0 {
+ portName := fmt.Sprintf("p%d", containerPort)
+ protocol := qovery.PORTPROTOCOLENUM_HTTP
+ ports = append(ports, qovery.ServicePortRequestPortsInner{
+ Name: &portName,
+ InternalPort: containerPort,
+ ExternalPort: utils.Int32(443),
+ PubliclyAccessible: true,
+ IsDefault: utils.Bool(true),
+ Protocol: &protocol,
+ })
+ }
+
+ req := qovery.ContainerRequest{
+ Name: containerName,
+ RegistryId: containerRegistryId,
+ ImageName: containerImageName,
+ Tag: containerTag,
+ Ports: ports,
+ Cpu: utils.Int32(containerCpu),
+ Memory: utils.Int32(containerMemory),
+ MinRunningInstances: utils.Int32(containerMinRunningInstances),
+ MaxRunningInstances: utils.Int32(containerMaxRunningInstances),
+ Healthchecks: *qovery.NewHealthcheck(),
+ }
+
+ created, res, err := client.ContainersAPI.CreateContainer(context.Background(), envId).ContainerRequest(req).Execute()
+ if err != nil && res != nil && res.StatusCode != 201 {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ }
+ utils.CheckError(err)
+
+ var publicLink string
+ if len(ports) > 0 {
+ links, _, err := client.ContainerMainCallsAPI.ListContainerLinks(context.Background(), created.Id).Execute()
+ if err == nil {
+ for _, link := range links.GetResults() {
+ publicLink = link.Url
+ break
+ }
+ }
+ }
+
+ if jsonFlag {
+ out := struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ PublicLink string `json:"public_link,omitempty"`
+ }{Id: created.Id, Name: created.Name, PublicLink: publicLink}
+ j, err := json.Marshal(out)
+ utils.CheckError(err)
+ utils.Println(string(j))
+ return
+ }
+
+ msg := fmt.Sprintf("Container service %s created! (id: %s)", pterm.FgBlue.Sprintf("%s", created.Name), pterm.FgBlue.Sprintf("%s", created.Id))
+ if publicLink != "" {
+ msg += fmt.Sprintf(" - Public link: %s", pterm.FgBlue.Sprintf("%s", publicLink))
+ }
+ utils.Println(msg)
+ },
+}
+
+func init() {
+ containerCmd.AddCommand(containerCreateCmd)
+ containerCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerCreateCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerCreateCmd.Flags().StringVarP(&containerRegistryId, "registry", "", "", "Container Registry ID")
+ containerCreateCmd.Flags().StringVarP(&containerImageName, "image-name", "", "", "Container Image Name")
+ containerCreateCmd.Flags().StringVarP(&containerTag, "tag", "t", "", "Container Image Tag")
+ containerCreateCmd.Flags().Int32VarP(&containerPort, "port", "p", 0, "Container Port (0 = no port exposed)")
+ containerCreateCmd.Flags().Int32VarP(&containerCpu, "cpu", "", 500, "CPU in millicores (e.g. 500 = 0.5 vCPU)")
+ containerCreateCmd.Flags().Int32VarP(&containerMemory, "memory", "", 512, "Memory in MB")
+ containerCreateCmd.Flags().Int32VarP(&containerMinRunningInstances, "min-instances", "", 1, "Minimum number of running instances")
+ containerCreateCmd.Flags().Int32VarP(&containerMaxRunningInstances, "max-instances", "", 1, "Maximum number of running instances")
+ containerCreateCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = containerCreateCmd.MarkFlagRequired("container")
+ _ = containerCreateCmd.MarkFlagRequired("registry")
+ _ = containerCreateCmd.MarkFlagRequired("image-name")
+ _ = containerCreateCmd.MarkFlagRequired("tag")
+}
diff --git a/cmd/container_delete.go b/cmd/container_delete.go
new file mode 100644
index 00000000..1fabf600
--- /dev/null
+++ b/cmd/container_delete.go
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete a container",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateContainerArguments(containerName, containerNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ containerList := buildContainerListFromContainerNames(client, envId, containerName, containerNames)
+ _, err := client.EnvironmentActionsAPI.
+ DeleteSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ ContainerIds: utils.Map(containerList, func(container *qovery.ContainerResponse) string {
+ return container.Id
+ }),
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to delete container(s) %s has been queued...", pterm.FgBlue.Sprintf("%s%s", containerName, containerNames)))
+ WatchContainerDeployment(client, envId, containerList, watchFlag, qovery.STATEENUM_DELETED)
+ },
+}
+
+func init() {
+ containerCmd.AddCommand(containerDeleteCmd)
+ containerDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerDeleteCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerDeleteCmd.Flags().StringVarP(&containerNames, "containers", "", "", "Container Names (comma separated) (ex: --containers \"container1,container2\")")
+ containerDeleteCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch container status until it's ready or an error occurs")
+}
diff --git a/cmd/container_deploy.go b/cmd/container_deploy.go
new file mode 100644
index 00000000..a5f74db7
--- /dev/null
+++ b/cmd/container_deploy.go
@@ -0,0 +1,57 @@
+package cmd
+
+import (
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "time"
+)
+
+var containerDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy a container",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateContainerArguments(containerName, containerNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ // deploy multiple services
+ containerList := buildContainerListFromContainerNames(client, envId, containerName, containerNames)
+ err := utils.DeployContainers(client, envId, containerList, containerTag)
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to deploy container(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", containerName, containerNames)))
+ WatchContainerDeployment(client, envId, containerList, watchFlag, qovery.STATEENUM_DEPLOYED)
+ },
+}
+
+func WatchContainerDeployment(
+ client *qovery.APIClient,
+ envId string,
+ containers []*qovery.ContainerResponse,
+ watchFlag bool,
+ finalServiceState qovery.StateEnum,
+) {
+ if watchFlag {
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent from race condition)
+ if len(containers) == 1 {
+ utils.WatchContainer(containers[0].Id, envId, client)
+ } else {
+ utils.WatchEnvironment(envId, finalServiceState, client)
+ }
+ }
+}
+
+func init() {
+ containerCmd.AddCommand(containerDeployCmd)
+ containerDeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerDeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerDeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerDeployCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerDeployCmd.Flags().StringVarP(&containerNames, "containers", "", "", "Container Names (comma separated) (ex: --containers \"container1,container2\")")
+ containerDeployCmd.Flags().StringVarP(&containerTag, "tag", "t", "", "Container Tag")
+ containerDeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch container status until it's ready or an error occurs")
+}
diff --git a/cmd/container_domain.go b/cmd/container_domain.go
new file mode 100644
index 00000000..2fa03bb4
--- /dev/null
+++ b/cmd/container_domain.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var containerDomainCmd = &cobra.Command{
+ Use: "domain",
+ Short: "Manage container domains",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ containerCmd.AddCommand(containerDomainCmd)
+}
diff --git a/cmd/container_domain_create.go b/cmd/container_domain_create.go
new file mode 100644
index 00000000..31a5537b
--- /dev/null
+++ b/cmd/container_domain_create.go
@@ -0,0 +1,102 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerDomainCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create container custom domain",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.ContainerCustomDomainAPI.ListContainerCustomDomain(context.Background(), container.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomain := utils.FindByCustomDomainName(customDomains.GetResults(), containerCustomDomain)
+ if customDomain != nil {
+ utils.PrintlnError(fmt.Errorf("custom domain %s already exists", containerCustomDomain))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ generateCertificate := !doNotGenerateCertificate
+ req := qovery.CustomDomainRequest{
+ Domain: containerCustomDomain,
+ GenerateCertificate: generateCertificate,
+ UseCdn: &useCdn,
+ }
+
+ createdDomain, _, err := client.ContainerCustomDomainAPI.CreateContainerCustomDomain(context.Background(), container.Id).CustomDomainRequest(req).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Custom domain %s has been created (generate certificate: %s)", pterm.FgBlue.Sprintf("%s", createdDomain.Domain), pterm.FgBlue.Sprintf("%s", strconv.FormatBool(createdDomain.GenerateCertificate))))
+ },
+}
+
+func init() {
+ containerDomainCmd.AddCommand(containerDomainCreateCmd)
+ containerDomainCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerDomainCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerDomainCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerDomainCreateCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerDomainCreateCmd.Flags().StringVarP(&containerCustomDomain, "domain", "", "", "Custom Domain ")
+ containerDomainCreateCmd.Flags().BoolVarP(&doNotGenerateCertificate, "do-not-generate-certificate", "", false, "Do Not Generate Certificate")
+ containerDomainCreateCmd.Flags().BoolVarP(&useCdn, "is-behind-a-cdn", "", false, "Custom Domain is behind a CDN")
+
+ _ = containerDomainCreateCmd.MarkFlagRequired("container")
+ _ = containerDomainCreateCmd.MarkFlagRequired("domain")
+}
diff --git a/cmd/container_domain_delete.go b/cmd/container_domain_delete.go
new file mode 100644
index 00000000..b6e9024b
--- /dev/null
+++ b/cmd/container_domain_delete.go
@@ -0,0 +1,90 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerDomainDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete container custom domain",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.ContainerCustomDomainAPI.ListContainerCustomDomain(context.Background(), container.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomain := utils.FindByCustomDomainName(customDomains.GetResults(), containerCustomDomain)
+ if customDomain == nil {
+ utils.PrintlnError(fmt.Errorf("custom domain %s does not exist", containerCustomDomain))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ _, err = client.ContainerCustomDomainAPI.DeleteContainerCustomDomain(context.Background(), container.Id, customDomain.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Custom domain %s has been deleted", pterm.FgBlue.Sprintf("%s", containerCustomDomain)))
+ },
+}
+
+func init() {
+ containerDomainCmd.AddCommand(containerDomainDeleteCmd)
+ containerDomainDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerDomainDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerDomainDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerDomainDeleteCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerDomainDeleteCmd.Flags().StringVarP(&containerCustomDomain, "domain", "", "", "Custom Domain ")
+
+ _ = containerDomainDeleteCmd.MarkFlagRequired("container")
+ _ = containerDomainDeleteCmd.MarkFlagRequired("domain")
+}
diff --git a/cmd/container_domain_edit.go b/cmd/container_domain_edit.go
new file mode 100644
index 00000000..9b4a86ea
--- /dev/null
+++ b/cmd/container_domain_edit.go
@@ -0,0 +1,101 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/qovery/qovery-client-go"
+ "os"
+ "strconv"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerDomainEditCmd = &cobra.Command{
+ Use: "edit",
+ Short: "Edit container custom domain",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.ContainerCustomDomainAPI.ListContainerCustomDomain(context.Background(), container.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomain := utils.FindByCustomDomainName(customDomains.GetResults(), containerCustomDomain)
+ if customDomain == nil {
+ utils.PrintlnError(fmt.Errorf("custom domain %s does not exist", containerCustomDomain))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ generateCertificate := !doNotGenerateCertificate
+ req := qovery.CustomDomainRequest{
+ Domain: containerCustomDomain,
+ GenerateCertificate: generateCertificate,
+ UseCdn: &useCdn,
+ }
+
+ editedDomain, _, err := client.ContainerCustomDomainAPI.EditContainerCustomDomain(context.Background(), container.Id, customDomain.Id).CustomDomainRequest(req).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Custom domain %s has been edited (generate certificate: %s)", pterm.FgBlue.Sprintf("%s", editedDomain.Domain), pterm.FgBlue.Sprintf("%s", strconv.FormatBool(editedDomain.GenerateCertificate))))
+ },
+}
+
+func init() {
+ containerDomainCmd.AddCommand(containerDomainEditCmd)
+ containerDomainEditCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerDomainEditCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerDomainEditCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerDomainEditCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerDomainEditCmd.Flags().StringVarP(&containerCustomDomain, "domain", "", "", "Custom Domain ")
+ containerDomainEditCmd.Flags().BoolVarP(&doNotGenerateCertificate, "do-not-generate-certificate", "", false, "Do Not Generate Certificate")
+ containerDomainEditCmd.Flags().BoolVarP(&useCdn, "is-behind-a-cdn", "", false, "Custom Domain is behind a CDN")
+
+ _ = containerDomainEditCmd.MarkFlagRequired("container")
+ _ = containerDomainEditCmd.MarkFlagRequired("domain")
+}
diff --git a/cmd/container_domain_list.go b/cmd/container_domain_list.go
new file mode 100644
index 00000000..c059844f
--- /dev/null
+++ b/cmd/container_domain_list.go
@@ -0,0 +1,151 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var containerDomainListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List container domains",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.ContainerCustomDomainAPI.ListContainerCustomDomain(context.Background(), container.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomainsSet := make(map[string]bool)
+ var data [][]string
+
+ for _, customDomain := range customDomains.GetResults() {
+ customDomainsSet[customDomain.Domain] = true
+
+ data = append(data, []string{
+ customDomain.Id,
+ "CUSTOM_DOMAIN",
+ customDomain.Domain,
+ *customDomain.ValidationDomain,
+ strconv.FormatBool(customDomain.GenerateCertificate),
+ })
+ }
+
+ links, _, err := client.ContainerMainCallsAPI.ListContainerLinks(context.Background(), container.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(getContainerDomainJsonOutput(links.GetResults(), customDomains.GetResults()))
+ return
+ }
+
+ for _, link := range links.GetResults() {
+ domain := strings.ReplaceAll(link.Url, "https://", "")
+ if !customDomainsSet[domain] {
+ data = append(data, []string{
+ "N/A",
+ "BUILT_IN_DOMAIN",
+ domain,
+ "N/A",
+ "N/A",
+ })
+ }
+ }
+
+ err = utils.PrintTable([]string{"Id", "Type", "Domain", "Validation Domain", "Generate Certificate"}, data)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getContainerDomainJsonOutput(links []qovery.Link, domains []qovery.CustomDomain) string {
+ var results []interface{}
+
+ for _, link := range links {
+ results = append(results, map[string]interface{}{
+ "id": nil,
+ "type": "BUILT_IN_DOMAIN",
+ "domain": strings.ReplaceAll(link.Url, "https://", ""),
+ "validation_domain": nil,
+ })
+ }
+
+ for _, domain := range domains {
+ results = append(results, map[string]interface{}{
+ "id": domain.Id,
+ "type": "CUSTOM_DOMAIN",
+ "domain": domain.Domain,
+ "validation_domain": *domain.ValidationDomain,
+ })
+ }
+
+ j, err := json.Marshal(results)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ containerDomainCmd.AddCommand(containerDomainListCmd)
+ containerDomainListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerDomainListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerDomainListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerDomainListCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerDomainListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = containerDomainListCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_env.go b/cmd/container_env.go
new file mode 100644
index 00000000..8a1a5fcb
--- /dev/null
+++ b/cmd/container_env.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var containerEnvCmd = &cobra.Command{
+ Use: "env",
+ Short: "Manage container environment variables and secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ containerCmd.AddCommand(containerEnvCmd)
+}
diff --git a/cmd/container_env_alias.go b/cmd/container_env_alias.go
new file mode 100644
index 00000000..5e0e1303
--- /dev/null
+++ b/cmd/container_env_alias.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var containerEnvAliasCmd = &cobra.Command{
+ Use: "alias",
+ Short: "Manage container environment variable and secret aliases",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ containerEnvCmd.AddCommand(containerEnvAliasCmd)
+}
diff --git a/cmd/container_env_alias_create.go b/cmd/container_env_alias_create.go
new file mode 100644
index 00000000..b8f4ada0
--- /dev/null
+++ b/cmd/container_env_alias_create.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerEnvAliasCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create container environment variable or secret alias",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceAlias(client, projectId, envId, container.Id, utils.ContainerType, utils.Key, utils.Alias, utils.ContainerScope)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Alias %s has been created", pterm.FgBlue.Sprintf("%s", utils.Alias)))
+ },
+}
+
+func init() {
+ containerEnvAliasCmd.AddCommand(containerEnvAliasCreateCmd)
+ containerEnvAliasCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerEnvAliasCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerEnvAliasCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerEnvAliasCreateCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerEnvAliasCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ containerEnvAliasCreateCmd.Flags().StringVarP(&utils.Alias, "alias", "", "", "Environment variable or secret alias")
+ containerEnvAliasCreateCmd.Flags().StringVarP(&utils.ContainerScope, "scope", "", "CONTAINER", "Scope of this alias ")
+
+ _ = containerEnvAliasCreateCmd.MarkFlagRequired("key")
+ _ = containerEnvAliasCreateCmd.MarkFlagRequired("alias")
+ _ = containerEnvAliasCreateCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_env_create.go b/cmd/container_env_create.go
new file mode 100644
index 00000000..1ca929ca
--- /dev/null
+++ b/cmd/container_env_create.go
@@ -0,0 +1,79 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerEnvCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create container environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceVariable(client, projectId, envId, container.Id, utils.ContainerScope, utils.Key, utils.Value, utils.IsSecret)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ containerEnvCmd.AddCommand(containerEnvCreateCmd)
+ containerEnvCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerEnvCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerEnvCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerEnvCreateCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerEnvCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ containerEnvCreateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+ containerEnvCreateCmd.Flags().StringVarP(&utils.ContainerScope, "scope", "", "CONTAINER", "Scope of this env var ")
+ containerEnvCreateCmd.Flags().BoolVarP(&utils.IsSecret, "secret", "", false, "This environment variable is a secret")
+
+ _ = containerEnvCreateCmd.MarkFlagRequired("key")
+ _ = containerEnvCreateCmd.MarkFlagRequired("value")
+ _ = containerEnvCreateCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_env_delete.go b/cmd/container_env_delete.go
new file mode 100644
index 00000000..203390f4
--- /dev/null
+++ b/cmd/container_env_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerEnvDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete container environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, container.Id, utils.ContainerType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Variable %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ containerEnvCmd.AddCommand(containerEnvDeleteCmd)
+ containerEnvDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerEnvDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerEnvDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerEnvDeleteCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerEnvDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+
+ _ = containerEnvDeleteCmd.MarkFlagRequired("key")
+ _ = containerEnvDeleteCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_env_list.go b/cmd/container_env_list.go
new file mode 100644
index 00000000..2cc0abd3
--- /dev/null
+++ b/cmd/container_env_list.go
@@ -0,0 +1,100 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var containerEnvListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List container environment variables",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVars, err := utils.ListServiceVariables(
+ client,
+ container.Id,
+ utils.ContainerType,
+ )
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVarLines := utils.NewEnvVarLines()
+ var variables []utils.EnvVarLineOutput
+
+ for _, envVar := range envVars {
+ s := utils.FromEnvironmentVariableToEnvVarLineOutput(envVar)
+ variables = append(variables, s)
+ envVarLines.Add(s)
+ }
+
+ if jsonFlag {
+ utils.Println(utils.GetEnvVarJsonOutput(variables, utils.SortKeys))
+ return
+ }
+
+ err = utils.PrintTable(envVarLines.Header(utils.PrettyPrint), envVarLines.Lines(utils.ShowValues, utils.PrettyPrint, utils.SortKeys))
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func init() {
+ containerEnvCmd.AddCommand(containerEnvListCmd)
+ containerEnvListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerEnvListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerEnvListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerEnvListCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerEnvListCmd.Flags().BoolVarP(&utils.ShowValues, "show-values", "", false, "Show env var values")
+ containerEnvListCmd.Flags().BoolVarP(&utils.PrettyPrint, "pretty-print", "", false, "Pretty print output")
+ containerEnvListCmd.Flags().BoolVarP(&utils.SortKeys, "sort", "", false, "Sort environment variables by key")
+ containerEnvListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = containerEnvListCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_env_override.go b/cmd/container_env_override.go
new file mode 100644
index 00000000..08fc0c86
--- /dev/null
+++ b/cmd/container_env_override.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var containerEnvOverrideCmd = &cobra.Command{
+ Use: "override",
+ Short: "Manage container environment variable and secret overrides",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ containerEnvCmd.AddCommand(containerEnvOverrideCmd)
+}
diff --git a/cmd/container_env_override_create.go b/cmd/container_env_override_create.go
new file mode 100644
index 00000000..8fc0a8e3
--- /dev/null
+++ b/cmd/container_env_override_create.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerEnvOverrideCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Override container environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceOverride(client, projectId, envId, container.Id, utils.ContainerType, utils.Key, utils.Value, utils.ContainerScope)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("%s has been overidden", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ containerEnvOverrideCmd.AddCommand(containerEnvOverrideCreateCmd)
+ containerEnvOverrideCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerEnvOverrideCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerEnvOverrideCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerEnvOverrideCreateCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerEnvOverrideCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ containerEnvOverrideCreateCmd.Flags().StringVarP(&utils.Value, "value", "", "", "Environment variable or secret value")
+ containerEnvOverrideCreateCmd.Flags().StringVarP(&utils.ContainerScope, "scope", "", "CONTAINER", "Scope of this alias ")
+
+ _ = containerEnvOverrideCreateCmd.MarkFlagRequired("key")
+ _ = containerEnvOverrideCreateCmd.MarkFlagRequired("container")
+ _ = containerEnvOverrideCreateCmd.MarkFlagRequired("value")
+}
diff --git a/cmd/container_env_update.go b/cmd/container_env_update.go
new file mode 100644
index 00000000..6994aeaf
--- /dev/null
+++ b/cmd/container_env_update.go
@@ -0,0 +1,76 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerEnvUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update container environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceVariable(client, utils.Key, utils.Value, container.Id, utils.ContainerType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ containerEnvCmd.AddCommand(containerEnvUpdateCmd)
+ containerEnvUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerEnvUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerEnvUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerEnvUpdateCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerEnvUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ containerEnvUpdateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+ _ = containerEnvUpdateCmd.MarkFlagRequired("key")
+ _ = containerEnvUpdateCmd.MarkFlagRequired("value")
+ _ = containerEnvUpdateCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_external_secret.go b/cmd/container_external_secret.go
new file mode 100644
index 00000000..dd349284
--- /dev/null
+++ b/cmd/container_external_secret.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var containerExternalSecretCmd = &cobra.Command{
+ Use: "external-secret",
+ Short: "Manage container external secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ containerCmd.AddCommand(containerExternalSecretCmd)
+}
diff --git a/cmd/container_external_secret_create.go b/cmd/container_external_secret_create.go
new file mode 100644
index 00000000..e452e8c1
--- /dev/null
+++ b/cmd/container_external_secret_create.go
@@ -0,0 +1,88 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerExternalSecretCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create container external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceExternalSecret(client, projectId, envId, container.Id, utils.ContainerScope, utils.Key, utils.Reference, secretManagerAccessId, utils.MountPath)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ containerExternalSecretCmd.AddCommand(containerExternalSecretCreateCmd)
+ containerExternalSecretCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerExternalSecretCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerExternalSecretCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerExternalSecretCreateCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerExternalSecretCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ containerExternalSecretCreateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "Reference to the secret in the secrets provider")
+ containerExternalSecretCreateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "Secret manager access name")
+ containerExternalSecretCreateCmd.Flags().StringVarP(&utils.ContainerScope, "scope", "", "CONTAINER", "Scope of this external secret ")
+ containerExternalSecretCreateCmd.Flags().StringVarP(&utils.MountPath, "mount-path", "", "", "Path where the secret will be mounted as a file")
+
+ _ = containerExternalSecretCreateCmd.MarkFlagRequired("key")
+ _ = containerExternalSecretCreateCmd.MarkFlagRequired("reference")
+ _ = containerExternalSecretCreateCmd.MarkFlagRequired("secret-manager-access-name")
+ _ = containerExternalSecretCreateCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_external_secret_delete.go b/cmd/container_external_secret_delete.go
new file mode 100644
index 00000000..03fdcc7f
--- /dev/null
+++ b/cmd/container_external_secret_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerExternalSecretDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete container external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, container.Id, utils.ContainerType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ containerExternalSecretCmd.AddCommand(containerExternalSecretDeleteCmd)
+ containerExternalSecretDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerExternalSecretDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerExternalSecretDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerExternalSecretDeleteCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerExternalSecretDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+
+ _ = containerExternalSecretDeleteCmd.MarkFlagRequired("key")
+ _ = containerExternalSecretDeleteCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_external_secret_update.go b/cmd/container_external_secret_update.go
new file mode 100644
index 00000000..84f67a2a
--- /dev/null
+++ b/cmd/container_external_secret_update.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerExternalSecretUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update container external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceExternalSecret(client, utils.Key, utils.Reference, secretManagerAccessId, container.Id, utils.ContainerType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ containerExternalSecretCmd.AddCommand(containerExternalSecretUpdateCmd)
+ containerExternalSecretUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerExternalSecretUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerExternalSecretUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerExternalSecretUpdateCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerExternalSecretUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ containerExternalSecretUpdateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "New reference to the secret in the secrets provider")
+ containerExternalSecretUpdateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "New secret manager access name")
+
+ _ = containerExternalSecretUpdateCmd.MarkFlagRequired("key")
+ _ = containerExternalSecretUpdateCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_list.go b/cmd/container_list.go
new file mode 100644
index 00000000..8717f128
--- /dev/null
+++ b/cmd/container_list.go
@@ -0,0 +1,102 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var containerListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List containers",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(getContainerJsonOutput(containers.GetResults(), statuses))
+ return
+ }
+
+ var data [][]string
+
+ for _, container := range containers.GetResults() {
+ data = append(data, []string{container.Id, container.Name, "Container",
+ utils.FindStatusTextWithColor(statuses.GetContainers(), container.Id), container.UpdatedAt.String()})
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name", "Type", "Status", "Last Update"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getContainerJsonOutput(containers []qovery.ContainerResponse, statuses *qovery.EnvironmentStatuses) string {
+ var results []interface{}
+
+ for _, container := range containers {
+ results = append(results, map[string]interface{}{
+ "id": container.Id,
+ "name": container.Name,
+ "type": "Container",
+ "status": utils.FindStatus(statuses.GetApplications(), container.Id),
+ "last_update": container.UpdatedAt.String(),
+ })
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ containerCmd.AddCommand(containerListCmd)
+ containerListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/container_redeploy.go b/cmd/container_redeploy.go
new file mode 100644
index 00000000..d4289a5c
--- /dev/null
+++ b/cmd/container_redeploy.go
@@ -0,0 +1,42 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerRedeployCmd = &cobra.Command{
+ Use: "redeploy",
+ Short: "Redeploy a container",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateContainerArguments(containerName, containerNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ containerList := buildContainerListFromContainerNames(client, envId, containerName, containerNames)
+
+ _, _, err := client.ContainerActionsAPI.DeployContainer(context.Background(), containerList[0].Id).
+ ContainerDeployRequest(qovery.ContainerDeployRequest{}).Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to redeploy container(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", containerName, containerNames)))
+ WatchContainerDeployment(client, envId, containerList, watchFlag, qovery.STATEENUM_RESTARTED)
+ },
+}
+
+func init() {
+ containerCmd.AddCommand(containerRedeployCmd)
+ containerRedeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerRedeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerRedeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerRedeployCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerRedeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch container status until it's ready or an error occurs")
+
+ _ = containerRedeployCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/container_registry.go b/cmd/container_registry.go
new file mode 100644
index 00000000..76ba752e
--- /dev/null
+++ b/cmd/container_registry.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var containerRegistryCmd = &cobra.Command{
+ Use: "registry",
+ Short: "Manage container registries",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ containerCmd.AddCommand(containerRegistryCmd)
+}
diff --git a/cmd/container_registry_list.go b/cmd/container_registry_list.go
new file mode 100644
index 00000000..acc2b166
--- /dev/null
+++ b/cmd/container_registry_list.go
@@ -0,0 +1,88 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var containerRegistryListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List container registries",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ utils.CheckError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
+ utils.CheckError(err)
+
+ registries, _, err := client.ContainerRegistriesAPI.ListContainerRegistry(context.Background(), organizationId).Execute()
+ utils.CheckError(err)
+
+ if jsonFlag {
+ utils.Println(getContainerRegistryJsonOutput(registries.GetResults()))
+ return
+ }
+
+ var data [][]string
+ for _, registry := range registries.GetResults() {
+ url := ""
+ if registry.Url != nil {
+ url = *registry.Url
+ }
+ kind := ""
+ if registry.Kind != nil {
+ kind = string(*registry.Kind)
+ }
+ name := ""
+ if registry.Name != nil {
+ name = *registry.Name
+ }
+ data = append(data, []string{registry.Id, name, kind, url})
+ }
+
+ utils.CheckError(utils.PrintTable([]string{"Id", "Name", "Kind", "URL"}, data))
+ },
+}
+
+func getContainerRegistryJsonOutput(registries []qovery.ContainerRegistryResponse) string {
+ var results []interface{}
+ for _, registry := range registries {
+ url := ""
+ if registry.Url != nil {
+ url = *registry.Url
+ }
+ kind := ""
+ if registry.Kind != nil {
+ kind = string(*registry.Kind)
+ }
+ name := ""
+ if registry.Name != nil {
+ name = *registry.Name
+ }
+ results = append(results, map[string]interface{}{
+ "id": registry.Id,
+ "name": name,
+ "kind": kind,
+ "url": url,
+ })
+ }
+
+ j, err := json.Marshal(results)
+ utils.CheckError(err)
+
+ return string(j)
+}
+
+func init() {
+ containerRegistryCmd.AddCommand(containerRegistryListCmd)
+ containerRegistryListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerRegistryListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/container_stop.go b/cmd/container_stop.go
new file mode 100644
index 00000000..4ad8921e
--- /dev/null
+++ b/cmd/container_stop.go
@@ -0,0 +1,98 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+ "strings"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var containerStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop a container",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateContainerArguments(containerName, containerNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ containerList := buildContainerListFromContainerNames(client, envId, containerName, containerNames)
+ _, err := client.EnvironmentActionsAPI.
+ StopSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ ContainerIds: utils.Map(containerList, func(container *qovery.ContainerResponse) string {
+ return container.Id
+ }),
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to stop container(s) %s has been queued...", containerName))
+ WatchContainerDeployment(client, envId, containerList, watchFlag, qovery.STATEENUM_STOPPED)
+ },
+}
+
+func buildContainerListFromContainerNames(
+ client *qovery.APIClient,
+ environmentId string,
+ containerName string,
+ containerNames string,
+) []*qovery.ContainerResponse {
+ var containerList []*qovery.ContainerResponse
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ if containerName != "" {
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ containerList = append(containerList, container)
+ }
+ if containerNames != "" {
+ for _, containerName := range strings.Split(containerNames, ",") {
+ trimmedContainerName := strings.TrimSpace(containerName)
+ container := utils.FindByContainerName(containers.GetResults(), trimmedContainerName)
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ containerList = append(containerList, container)
+ }
+ }
+
+ return containerList
+}
+
+func validateContainerArguments(containerName string, containerNames string) {
+ if containerName == "" && containerNames == "" {
+ utils.PrintlnError(fmt.Errorf("use either --container \"\" or --containers \", \" but not both at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if containerName != "" && containerNames != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --container and --containers at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
+
+func init() {
+ containerCmd.AddCommand(containerStopCmd)
+ containerStopCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerStopCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerStopCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerStopCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerStopCmd.Flags().StringVarP(&containerNames, "containers", "", "", "Container Names (comma separated) (ex: --containers \"container1,container2\")")
+ containerStopCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch container status until it's ready or an error occurs")
+}
diff --git a/cmd/container_update.go b/cmd/container_update.go
new file mode 100644
index 00000000..2fcdbc2c
--- /dev/null
+++ b/cmd/container_update.go
@@ -0,0 +1,133 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/pkg/errors"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var containerUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update a container",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ utils.PrintlnError(fmt.Errorf("container %s not found", containerName))
+ utils.PrintlnInfo("You can list all containers with: qovery container list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ var storage []qovery.ServiceStorageRequestStorageInner
+ for _, s := range container.Storage {
+ storage = append(storage, qovery.ServiceStorageRequestStorageInner{
+ Id: &s.Id,
+ Type: s.Type,
+ Size: s.Size,
+ MountPoint: s.MountPoint,
+ })
+ }
+
+ var ports []qovery.ServicePortRequestPortsInner
+ for _, p := range container.Ports {
+ ports = append(ports, qovery.ServicePortRequestPortsInner{
+ Name: p.Name,
+ InternalPort: p.InternalPort,
+ ExternalPort: p.ExternalPort,
+ PubliclyAccessible: p.PubliclyAccessible,
+ IsDefault: p.IsDefault,
+ Protocol: &p.Protocol,
+ })
+ }
+
+ imageName := container.ImageName
+ if containerImageName != "" {
+ imageName = containerImageName
+ }
+
+ tag := container.Tag
+ if containerTag != "" {
+ tag = containerTag
+ }
+
+ req := qovery.ContainerRequest{
+ Storage: storage,
+ Ports: ports,
+ Name: container.Name,
+ Description: container.Description,
+ RegistryId: container.Registry.Id,
+ ImageName: imageName,
+ Tag: tag,
+ Arguments: container.Arguments,
+ Entrypoint: container.Entrypoint,
+ Cpu: utils.Int32(container.Cpu),
+ Memory: utils.Int32(container.Memory),
+ MinRunningInstances: utils.Int32(container.MinRunningInstances),
+ MaxRunningInstances: utils.Int32(container.MaxRunningInstances),
+ Healthchecks: container.Healthchecks,
+ AutoPreview: utils.Bool(container.AutoPreview),
+ AutoDeploy: *qovery.NewNullableBool(container.AutoDeploy),
+ Autoscaling: utils.ConvertAutoscalingResponseToRequest(container.Autoscaling),
+ }
+
+ _, res, err := client.ContainerMainCallsAPI.EditContainer(context.Background(), container.Id).ContainerRequest(req).Execute()
+ if err != nil {
+ // print http body error message
+ if res.StatusCode != 200 {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ }
+
+ utils.PrintlnError(err)
+
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Container %s updated!", pterm.FgBlue.Sprintf("%s", containerName)))
+ },
+}
+
+func init() {
+ containerCmd.AddCommand(containerUpdateCmd)
+ containerUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ containerUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ containerUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ containerUpdateCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ containerUpdateCmd.Flags().StringVarP(&containerImageName, "image-name", "", "", "Container Image Name")
+ containerUpdateCmd.Flags().StringVarP(&containerTag, "tag", "", "", "Container Tag")
+
+ _ = containerUpdateCmd.MarkFlagRequired("container")
+}
diff --git a/cmd/context.go b/cmd/context.go
index 286ac6ea..f32e9cee 100644
--- a/cmd/context.go
+++ b/cmd/context.go
@@ -8,11 +8,11 @@ import (
var contextCmd = &cobra.Command{
Use: "context",
- Short: "Manage Qovery CLI context",
+ Short: "Manage CLI context",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)
utils.PrintlnInfo("Current context:")
- err := utils.PrintlnContext()
+ err := utils.PrintContext()
if err != nil {
fmt.Println("Context not yet configured. ")
}
diff --git a/cmd/context_set.go b/cmd/context_set.go
index 27a76c5f..1ec9c574 100644
--- a/cmd/context_set.go
+++ b/cmd/context_set.go
@@ -1,8 +1,6 @@
package cmd
import (
- "fmt"
-
"github.com/qovery/qovery-cli/utils"
"github.com/spf13/cobra"
)
@@ -12,41 +10,7 @@ var setCmd = &cobra.Command{
Short: "Set Qovery CLI context",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)
- utils.PrintlnInfo("Current context:")
- err := utils.PrintlnContext()
- if err != nil {
- fmt.Println("Context not yet configured. ")
- }
- println()
- _ = utils.ResetApplicationContext()
- utils.PrintlnInfo("Select new context")
- orga, err := utils.SelectAndSetOrganization()
- if err != nil {
- utils.PrintlnError(err)
- return
- }
-
- project, err := utils.SelectAndSetProject(orga.ID)
- if err != nil {
- utils.PrintlnError(err)
- return
- }
-
- env, err := utils.SelectAndSetEnvironment(project.ID)
- if err != nil {
- utils.PrintlnError(err)
- return
- }
-
- _, err = utils.SelectAndSetApplication(env.ID)
- if err != nil {
- utils.PrintlnError(err)
- return
- }
- _, _, _ = utils.CurrentApplication()
- println()
- utils.PrintlnInfo("New context:")
- err = utils.PrintlnContext()
+ err := utils.SetContext(true, true, true, true)
if err != nil {
utils.PrintlnError(err)
}
diff --git a/cmd/cronjob.go b/cmd/cronjob.go
new file mode 100644
index 00000000..6253f8dd
--- /dev/null
+++ b/cmd/cronjob.go
@@ -0,0 +1,54 @@
+package cmd
+
+import (
+ "context"
+ "os"
+
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobName string
+var cronjobNames string
+var cronjobCommitId string
+var cronjobBranch string
+var cronjobTag string
+var cronjobImageName string
+
+var targetCronjobName string
+
+var cronjobCmd = &cobra.Command{
+ Use: "cronjob",
+ Short: "Manage cronjobs",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(cronjobCmd)
+}
+
+func ListCronjobs(envId string, client *qovery.APIClient) ([]qovery.JobResponse, error) {
+ jobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ return nil, err
+ }
+
+ cronjobs := make([]qovery.JobResponse, 0)
+ for _, job := range jobs.GetResults() {
+ if job.CronJobResponse != nil {
+ cronjobs = append(cronjobs, job)
+ }
+ }
+
+ return cronjobs, nil
+}
diff --git a/cmd/cronjob_cancel.go b/cmd/cronjob_cancel.go
new file mode 100644
index 00000000..3b7e5414
--- /dev/null
+++ b/cmd/cronjob_cancel.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobCancelCmd = &cobra.Command{
+ Use: "cancel",
+ Short: "Cancel a cronjob deployment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ msg, err := utils.CancelServiceDeployment(client, envId, cronjob.CronJobResponse.Id, utils.JobType, watchFlag)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if msg != "" {
+ utils.PrintlnInfo(msg)
+ return
+ }
+
+ utils.Println(fmt.Sprintf("Cronjob %s deployment cancelled!", pterm.FgBlue.Sprintf("%s", cronjobName)))
+ },
+}
+
+func init() {
+ cronjobCmd.AddCommand(cronjobCancelCmd)
+ cronjobCancelCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobCancelCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobCancelCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobCancelCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobCancelCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cancel until it's done or an error occurs")
+
+ _ = cronjobCancelCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_clone.go b/cmd/cronjob_clone.go
new file mode 100644
index 00000000..34b35919
--- /dev/null
+++ b/cmd/cronjob_clone.go
@@ -0,0 +1,116 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/go-errors/errors"
+ "io"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobCloneCmd = &cobra.Command{
+ Use: "clone",
+ Short: "Clone a cronjob",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ job, err := getJobContextResource(client, cronjobName, envId)
+
+ if err != nil || job == nil || job.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjobName %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all jobs with: qovery job list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ targetProjectId := projectId // use same project as the source project
+ if targetProjectName != "" {
+
+ targetProjectId, err = getProjectContextResourceId(client, targetProjectName, organizationId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ targetEnvironmentId := envId // use same env as the source env
+ if targetEnvironmentName != "" {
+
+ targetEnvironmentId, err = getEnvironmentContextResourceId(client, targetEnvironmentName, targetProjectId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ if targetCronjobName == "" {
+ // use same job name as the source job
+ targetCronjobName = job.CronJobResponse.Name
+ }
+
+ req := qovery.CloneServiceRequest{
+ Name: targetCronjobName,
+ EnvironmentId: targetEnvironmentId,
+ }
+
+ clonedService, res, err := client.JobsAPI.CloneJob(context.Background(), job.CronJobResponse.Id).CloneServiceRequest(req).Execute()
+
+ if err != nil {
+ // print http body error message
+ if res.StatusCode != 200 {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ }
+
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ name := ""
+ if clonedService != nil {
+ name = clonedService.CronJobResponse.Name
+ }
+
+ utils.Println(fmt.Sprintf("Job %s cloned!", pterm.FgBlue.Sprintf("%s", name)))
+ },
+}
+
+func init() {
+ cronjobCmd.AddCommand(cronjobCloneCmd)
+ cronjobCloneCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobCloneCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobCloneCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobCloneCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobCloneCmd.Flags().StringVarP(&targetProjectName, "target-project", "", "", "Target Project Name")
+ cronjobCloneCmd.Flags().StringVarP(&targetEnvironmentName, "target-environment", "", "", "Target Environment Name")
+ cronjobCloneCmd.Flags().StringVarP(&targetCronjobName, "target-cronjob-name", "", "", "Target Cronjob Name")
+
+ _ = cronjobCloneCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_delete.go b/cmd/cronjob_delete.go
new file mode 100644
index 00000000..aeb8fd08
--- /dev/null
+++ b/cmd/cronjob_delete.go
@@ -0,0 +1,43 @@
+package cmd
+
+import (
+ "context"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete a cronjob",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateCronjobArguments(cronjobName, cronjobNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ cronJobList := buildCronJobListFromCronjobNames(client, envId, cronjobName, cronjobNames)
+ _, err := client.EnvironmentActionsAPI.
+ DeleteSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ JobIds: utils.Map(cronJobList, func(job *qovery.JobResponse) string {
+ return utils.GetJobId(job)
+ }),
+ }).
+ Execute()
+ checkError(err)
+ WatchJobDeployment(client, envId, cronJobList, watchFlag, qovery.STATEENUM_DELETED)
+ },
+}
+
+func init() {
+ cronjobCmd.AddCommand(cronjobDeleteCmd)
+ cronjobDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobDeleteCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobDeleteCmd.Flags().StringVarP(&cronjobNames, "cronjobs", "", "", "Cronjob Names (comma separated) (ex: --cronjobs \"cron1,cron2\")")
+ cronjobDeleteCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cronjob status until it's ready or an error occurs")
+}
diff --git a/cmd/cronjob_deploy.go b/cmd/cronjob_deploy.go
new file mode 100644
index 00000000..86fdfbf9
--- /dev/null
+++ b/cmd/cronjob_deploy.go
@@ -0,0 +1,65 @@
+package cmd
+
+import (
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "os"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy a cronjob",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateCronjobArguments(cronjobName, cronjobNames)
+ if cronjobTag != "" && cronjobCommitId != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --tag and --commit-id at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ cronJobList := buildCronJobListFromCronjobNames(client, envId, cronjobName, cronjobNames)
+ err := utils.DeployJobs(client, envId, cronJobList, cronjobCommitId, cronjobTag)
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to deploy cronjob(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", cronjobName, cronjobNames)))
+ WatchJobDeployment(client, envId, cronJobList, watchFlag, qovery.STATEENUM_DEPLOYED)
+ },
+}
+
+func WatchJobDeployment(
+ client *qovery.APIClient,
+ envId string,
+ cronJobs []*qovery.JobResponse,
+ watchFlag bool,
+ finalServiceState qovery.StateEnum,
+) {
+ if watchFlag {
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent from race condition)
+ if len(cronJobs) == 1 {
+ utils.WatchJob(utils.GetJobId(cronJobs[0]), envId, client)
+ } else {
+ utils.WatchEnvironment(envId, finalServiceState, client)
+ }
+ }
+}
+
+func init() {
+ cronjobCmd.AddCommand(cronjobDeployCmd)
+ cronjobDeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobDeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobDeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobDeployCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobDeployCmd.Flags().StringVarP(&cronjobNames, "cronjobs", "", "", "Cronjob Names (comma separated) (ex: --cronjobs \"cron1,cron2\")")
+ cronjobDeployCmd.Flags().StringVarP(&cronjobCommitId, "commit-id", "c", "", "Cronjob Commit ID")
+ cronjobDeployCmd.Flags().StringVarP(&cronjobTag, "tag", "t", "", "Cronjob Tag")
+ cronjobDeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cronjob status until it's ready or an error occurs")
+}
diff --git a/cmd/cronjob_env.go b/cmd/cronjob_env.go
new file mode 100644
index 00000000..cc6887cc
--- /dev/null
+++ b/cmd/cronjob_env.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var cronjobEnvCmd = &cobra.Command{
+ Use: "env",
+ Short: "Manage cronjob environment variables and secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ cronjobCmd.AddCommand(cronjobEnvCmd)
+}
diff --git a/cmd/cronjob_env_alias.go b/cmd/cronjob_env_alias.go
new file mode 100644
index 00000000..700a2a68
--- /dev/null
+++ b/cmd/cronjob_env_alias.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var cronjobEnvAliasCmd = &cobra.Command{
+ Use: "alias",
+ Short: "Manage cronjob environment variable and secret aliases",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ cronjobEnvCmd.AddCommand(cronjobEnvAliasCmd)
+}
diff --git a/cmd/cronjob_env_alias_create.go b/cmd/cronjob_env_alias_create.go
new file mode 100644
index 00000000..88c55c9a
--- /dev/null
+++ b/cmd/cronjob_env_alias_create.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobEnvAliasCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create cronjob environment variable or secret alias",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceAlias(client, projectId, envId, cronjob.CronJobResponse.Id, utils.JobType, utils.Key, utils.Alias, utils.JobScope)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Alias %s has been created", pterm.FgBlue.Sprintf("%s", utils.Alias)))
+ },
+}
+
+func init() {
+ cronjobEnvAliasCmd.AddCommand(cronjobEnvAliasCreateCmd)
+ cronjobEnvAliasCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobEnvAliasCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobEnvAliasCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobEnvAliasCreateCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobEnvAliasCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ cronjobEnvAliasCreateCmd.Flags().StringVarP(&utils.Alias, "alias", "", "", "Environment variable or secret alias")
+ cronjobEnvAliasCreateCmd.Flags().StringVarP(&utils.JobScope, "scope", "", "JOB", "Scope of this alias ")
+
+ _ = cronjobEnvAliasCreateCmd.MarkFlagRequired("key")
+ _ = cronjobEnvAliasCreateCmd.MarkFlagRequired("alias")
+ _ = cronjobEnvAliasCreateCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_env_create.go b/cmd/cronjob_env_create.go
new file mode 100644
index 00000000..03fbbdc2
--- /dev/null
+++ b/cmd/cronjob_env_create.go
@@ -0,0 +1,79 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobEnvCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create cronjob environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceVariable(client, projectId, envId, cronjob.CronJobResponse.Id, utils.JobScope, utils.Key, utils.Value, utils.IsSecret)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ cronjobEnvCmd.AddCommand(cronjobEnvCreateCmd)
+ cronjobEnvCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobEnvCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobEnvCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobEnvCreateCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobEnvCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ cronjobEnvCreateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+ cronjobEnvCreateCmd.Flags().StringVarP(&utils.JobScope, "scope", "", "JOB", "Scope of this env var ")
+ cronjobEnvCreateCmd.Flags().BoolVarP(&utils.IsSecret, "secret", "", false, "This environment variable is a secret")
+
+ _ = cronjobEnvCreateCmd.MarkFlagRequired("key")
+ _ = cronjobEnvCreateCmd.MarkFlagRequired("value")
+ _ = cronjobEnvCreateCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_env_delete.go b/cmd/cronjob_env_delete.go
new file mode 100644
index 00000000..055f9b13
--- /dev/null
+++ b/cmd/cronjob_env_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobEnvDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete cronjob environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, cronjob.CronJobResponse.Id, utils.JobType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Variable %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ cronjobEnvCmd.AddCommand(cronjobEnvDeleteCmd)
+ cronjobEnvDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobEnvDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobEnvDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobEnvDeleteCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobEnvDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+
+ _ = cronjobEnvDeleteCmd.MarkFlagRequired("key")
+ _ = cronjobEnvDeleteCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_env_list.go b/cmd/cronjob_env_list.go
new file mode 100644
index 00000000..03014465
--- /dev/null
+++ b/cmd/cronjob_env_list.go
@@ -0,0 +1,100 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var cronjobEnvListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List cronjob environment variables",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVars, err := utils.ListServiceVariables(
+ client,
+ cronjob.CronJobResponse.Id,
+ utils.JobType,
+ )
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVarLines := utils.NewEnvVarLines()
+ var variables []utils.EnvVarLineOutput
+
+ for _, envVar := range envVars {
+ s := utils.FromEnvironmentVariableToEnvVarLineOutput(envVar)
+ variables = append(variables, s)
+ envVarLines.Add(s)
+ }
+
+ if jsonFlag {
+ utils.Println(utils.GetEnvVarJsonOutput(variables, utils.SortKeys))
+ return
+ }
+
+ err = utils.PrintTable(envVarLines.Header(utils.PrettyPrint), envVarLines.Lines(utils.ShowValues, utils.PrettyPrint, utils.SortKeys))
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func init() {
+ cronjobEnvCmd.AddCommand(cronjobEnvListCmd)
+ cronjobEnvListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobEnvListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobEnvListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobEnvListCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobEnvListCmd.Flags().BoolVarP(&utils.ShowValues, "show-values", "", false, "Show env var values")
+ cronjobEnvListCmd.Flags().BoolVarP(&utils.PrettyPrint, "pretty-print", "", false, "Pretty print output")
+ cronjobEnvListCmd.Flags().BoolVarP(&utils.SortKeys, "sort", "", false, "Sort environment variables by key")
+ cronjobEnvListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = cronjobEnvListCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_env_override.go b/cmd/cronjob_env_override.go
new file mode 100644
index 00000000..fc424c6d
--- /dev/null
+++ b/cmd/cronjob_env_override.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var cronjobEnvOverrideCmd = &cobra.Command{
+ Use: "override",
+ Short: "Manage cronjob environment variable and secret overrides",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ cronjobEnvCmd.AddCommand(cronjobEnvOverrideCmd)
+}
diff --git a/cmd/cronjob_env_override_create.go b/cmd/cronjob_env_override_create.go
new file mode 100644
index 00000000..a9f10c47
--- /dev/null
+++ b/cmd/cronjob_env_override_create.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobEnvOverrideCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Override cronjob environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceOverride(client, projectId, envId, cronjob.CronJobResponse.Id, utils.JobType, utils.Key, utils.Value, utils.JobScope)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("%s has been overidden", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ cronjobEnvOverrideCmd.AddCommand(cronjobEnvOverrideCreateCmd)
+ cronjobEnvOverrideCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobEnvOverrideCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobEnvOverrideCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobEnvOverrideCreateCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobEnvOverrideCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ cronjobEnvOverrideCreateCmd.Flags().StringVarP(&utils.Value, "value", "", "", "Environment variable or secret value")
+ cronjobEnvOverrideCreateCmd.Flags().StringVarP(&utils.JobScope, "scope", "", "JOB", "Scope of this alias ")
+
+ _ = cronjobEnvOverrideCreateCmd.MarkFlagRequired("key")
+ _ = cronjobEnvOverrideCreateCmd.MarkFlagRequired("cronjob")
+ _ = cronjobEnvOverrideCreateCmd.MarkFlagRequired("value")
+}
diff --git a/cmd/cronjob_env_update.go b/cmd/cronjob_env_update.go
new file mode 100644
index 00000000..66eae70d
--- /dev/null
+++ b/cmd/cronjob_env_update.go
@@ -0,0 +1,76 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobEnvUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update cronjob environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceVariable(client, utils.Key, utils.Value, cronjob.CronJobResponse.Id, utils.JobType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ cronjobEnvCmd.AddCommand(cronjobEnvUpdateCmd)
+ cronjobEnvUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobEnvUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobEnvUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobEnvUpdateCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobEnvUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ cronjobEnvUpdateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+ _ = cronjobEnvUpdateCmd.MarkFlagRequired("key")
+ _ = cronjobEnvUpdateCmd.MarkFlagRequired("value")
+ _ = cronjobEnvUpdateCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_external_secret.go b/cmd/cronjob_external_secret.go
new file mode 100644
index 00000000..424f04ba
--- /dev/null
+++ b/cmd/cronjob_external_secret.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var cronjobExternalSecretCmd = &cobra.Command{
+ Use: "external-secret",
+ Short: "Manage cronjob external secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ cronjobCmd.AddCommand(cronjobExternalSecretCmd)
+}
diff --git a/cmd/cronjob_external_secret_create.go b/cmd/cronjob_external_secret_create.go
new file mode 100644
index 00000000..cfd4332c
--- /dev/null
+++ b/cmd/cronjob_external_secret_create.go
@@ -0,0 +1,88 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobExternalSecretCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create cronjob external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceExternalSecret(client, projectId, envId, cronjob.CronJobResponse.Id, utils.JobScope, utils.Key, utils.Reference, secretManagerAccessId, utils.MountPath)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ cronjobExternalSecretCmd.AddCommand(cronjobExternalSecretCreateCmd)
+ cronjobExternalSecretCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobExternalSecretCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobExternalSecretCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobExternalSecretCreateCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobExternalSecretCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ cronjobExternalSecretCreateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "Reference to the secret in the secrets provider")
+ cronjobExternalSecretCreateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "Secret manager access name")
+ cronjobExternalSecretCreateCmd.Flags().StringVarP(&utils.JobScope, "scope", "", "JOB", "Scope of this external secret ")
+ cronjobExternalSecretCreateCmd.Flags().StringVarP(&utils.MountPath, "mount-path", "", "", "Path where the secret will be mounted as a file")
+
+ _ = cronjobExternalSecretCreateCmd.MarkFlagRequired("key")
+ _ = cronjobExternalSecretCreateCmd.MarkFlagRequired("reference")
+ _ = cronjobExternalSecretCreateCmd.MarkFlagRequired("secret-manager-access-name")
+ _ = cronjobExternalSecretCreateCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_external_secret_delete.go b/cmd/cronjob_external_secret_delete.go
new file mode 100644
index 00000000..af94e186
--- /dev/null
+++ b/cmd/cronjob_external_secret_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobExternalSecretDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete cronjob external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, cronjob.CronJobResponse.Id, utils.JobType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ cronjobExternalSecretCmd.AddCommand(cronjobExternalSecretDeleteCmd)
+ cronjobExternalSecretDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobExternalSecretDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobExternalSecretDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobExternalSecretDeleteCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobExternalSecretDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+
+ _ = cronjobExternalSecretDeleteCmd.MarkFlagRequired("key")
+ _ = cronjobExternalSecretDeleteCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_external_secret_update.go b/cmd/cronjob_external_secret_update.go
new file mode 100644
index 00000000..cbbd0781
--- /dev/null
+++ b/cmd/cronjob_external_secret_update.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobExternalSecretUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update cronjob external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceExternalSecret(client, utils.Key, utils.Reference, secretManagerAccessId, cronjob.CronJobResponse.Id, utils.JobType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ cronjobExternalSecretCmd.AddCommand(cronjobExternalSecretUpdateCmd)
+ cronjobExternalSecretUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobExternalSecretUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobExternalSecretUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobExternalSecretUpdateCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobExternalSecretUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ cronjobExternalSecretUpdateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "New reference to the secret in the secrets provider")
+ cronjobExternalSecretUpdateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "New secret manager access name")
+
+ _ = cronjobExternalSecretUpdateCmd.MarkFlagRequired("key")
+ _ = cronjobExternalSecretUpdateCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_list.go b/cmd/cronjob_list.go
new file mode 100644
index 00000000..91f01441
--- /dev/null
+++ b/cmd/cronjob_list.go
@@ -0,0 +1,108 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "github.com/qovery/qovery-client-go"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var cronjobListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List cronjobs",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, err := ListCronjobs(envId, client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ fmt.Print(getCronjobJsonOutput(statuses.GetJobs(), cronjobs))
+ return
+ }
+
+ var data [][]string
+
+ for _, cronjob := range cronjobs {
+ if cronjob.CronJobResponse != nil {
+ data = append(data, []string{cronjob.CronJobResponse.Id, cronjob.CronJobResponse.Name, "Cronjob",
+ utils.FindStatusTextWithColor(statuses.GetJobs(), cronjob.CronJobResponse.Id), cronjob.CronJobResponse.UpdatedAt.String()})
+ }
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name", "Type", "Status", "Last Update"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getCronjobJsonOutput(statuses []qovery.Status, cronjobs []qovery.JobResponse) string {
+ var results []interface{}
+
+ for _, cronjob := range cronjobs {
+ if cronjob.CronJobResponse != nil {
+ results = append(results, map[string]interface{}{
+ "id": cronjob.CronJobResponse.Id,
+ "name": cronjob.CronJobResponse.Name,
+ "type": "Cronjob",
+ "status": utils.FindStatus(statuses, cronjob.CronJobResponse.Id),
+ "updated_at": utils.ToIso8601(cronjob.CronJobResponse.UpdatedAt),
+ })
+ }
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ cronjobCmd.AddCommand(cronjobListCmd)
+ cronjobListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/cronjob_redeploy.go b/cmd/cronjob_redeploy.go
new file mode 100644
index 00000000..cdad6a3a
--- /dev/null
+++ b/cmd/cronjob_redeploy.go
@@ -0,0 +1,42 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobRedeployCmd = &cobra.Command{
+ Use: "redeploy",
+ Short: "Redeploy a cronjob",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateCronjobArguments(cronjobName, cronjobNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+ cronJobList := buildCronJobListFromCronjobNames(client, envId, cronjobName, cronjobNames)
+
+ _, _, err := client.JobActionsAPI.DeployJob(context.Background(), utils.GetJobId(cronJobList[0])).
+ JobDeployRequest(qovery.JobDeployRequest{}).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to redeploy cronjob(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", cronjobName, cronjobNames)))
+ WatchJobDeployment(client, envId, cronJobList, watchFlag, qovery.STATEENUM_RESTARTED)
+ },
+}
+
+func init() {
+ cronjobCmd.AddCommand(cronjobRedeployCmd)
+ cronjobRedeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobRedeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobRedeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobRedeployCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobRedeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cronjob status until it's ready or an error occurs")
+
+ _ = cronjobRedeployCmd.MarkFlagRequired("cronjob")
+}
diff --git a/cmd/cronjob_stop.go b/cmd/cronjob_stop.go
new file mode 100644
index 00000000..572e1a61
--- /dev/null
+++ b/cmd/cronjob_stop.go
@@ -0,0 +1,100 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop a cronjob",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateCronjobArguments(cronjobName, cronjobNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ cronJobList := buildCronJobListFromCronjobNames(client, envId, cronjobName, cronjobNames)
+ _, err := client.EnvironmentActionsAPI.
+ StopSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ JobIds: utils.Map(cronJobList, func(job *qovery.JobResponse) string {
+ return utils.GetJobId(job)
+ }),
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to stop cronjob(s) %s has been queued...", pterm.FgBlue.Sprintf("%s%s", cronjobName, cronjobNames)))
+ WatchJobDeployment(client, envId, cronJobList, watchFlag, qovery.STATEENUM_STOPPED)
+ },
+}
+
+func buildCronJobListFromCronjobNames(
+ client *qovery.APIClient,
+ environmentId string,
+ cronjobName string,
+ cronjobNames string,
+) []*qovery.JobResponse {
+ var cronjobList []*qovery.JobResponse
+ cronjobs, _, err := client.JobsAPI.ListJobs(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ if cronjobName != "" {
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), cronjobName)
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ cronjobList = append(cronjobList, cronjob)
+ }
+ if cronjobNames != "" {
+ for _, cronjobName := range strings.Split(cronjobNames, ",") {
+ trimmedCronjobName := strings.TrimSpace(cronjobName)
+ cronjob := utils.FindByJobName(cronjobs.GetResults(), trimmedCronjobName)
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ cronjobList = append(cronjobList, cronjob)
+ }
+ }
+
+ return cronjobList
+}
+
+func validateCronjobArguments(cronJobName string, cronJobNames string) {
+ if cronJobName == "" && cronJobNames == "" {
+ utils.PrintlnError(fmt.Errorf("use either --cronjob \"\" or --cronjobs \", \" but not both at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if cronJobName != "" && cronJobNames != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --cronjob and --cronjobs at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
+
+func init() {
+ cronjobCmd.AddCommand(cronjobStopCmd)
+ cronjobStopCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobStopCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobStopCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobStopCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobStopCmd.Flags().StringVarP(&cronjobNames, "cronjobs", "", "", "Cronjob Names (comma separated) (ex: --cronjobs \"cron1,cron2\")")
+ cronjobStopCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cronjob status until it's ready or an error occurs")
+}
diff --git a/cmd/cronjob_update.go b/cmd/cronjob_update.go
new file mode 100644
index 00000000..9e6c87cd
--- /dev/null
+++ b/cmd/cronjob_update.go
@@ -0,0 +1,119 @@
+package cmd
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "context"
+
+ "github.com/pkg/errors"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var cronjobUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update a cronjob",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if (cronjobTag != "" || cronjobImageName != "") && cronjobBranch != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --tag or --image-name with --branch at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if cronjobTag == "" && cronjobImageName == "" && cronjobBranch == "" {
+ utils.PrintlnError(fmt.Errorf("you must use --tag or --image-name or --branch"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjobs, err := ListCronjobs(envId, client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ cronjob := utils.FindByJobName(cronjobs, cronjobName)
+
+ if cronjob == nil || cronjob.CronJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", cronjobName))
+ utils.PrintlnInfo("You can list all cronjobs with: qovery cronjob list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ var docker = utils.GetJobDocker(cronjob)
+ var image = utils.GetJobImage(cronjob)
+
+ if docker != nil && (cronjobTag != "" || cronjobImageName != "") {
+ utils.PrintlnError(fmt.Errorf("you can't use --tag or --image-name with a cronjob targetting a Dockerfile. Use --branch instead"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if image != nil && cronjobBranch != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --branch with a cronjob targetting an image. Use --tag and/or --image-name instead"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ req := utils.ToJobRequest(*cronjob)
+
+ if docker != nil {
+ req.Source.Docker.Get().GitRepository.Branch = &cronjobBranch
+ req.Source.Image.Set(nil)
+ } else {
+ if cronjobTag != "" {
+ req.Source.Image.Get().Tag = &cronjobTag
+ }
+ if cronjobImageName != "" {
+ req.Source.Image.Get().ImageName = &cronjobImageName
+ }
+ req.Source.Docker.Set(nil)
+ }
+
+ _, res, err := client.JobMainCallsAPI.EditJob(context.Background(), utils.GetJobId(cronjob)).JobRequest(req).Execute()
+
+ if err != nil {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Cronjob %s updated!", pterm.FgBlue.Sprintf("%s", cronjobName)))
+ },
+}
+
+func init() {
+ cronjobCmd.AddCommand(cronjobUpdateCmd)
+ cronjobUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ cronjobUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ cronjobUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ cronjobUpdateCmd.Flags().StringVarP(&cronjobName, "cronjob", "n", "", "Cronjob Name")
+ cronjobUpdateCmd.Flags().StringVarP(&cronjobBranch, "branch", "b", "", "Cronjob Branch")
+ cronjobUpdateCmd.Flags().StringVarP(&cronjobTag, "tag", "t", "", "Cronjob Tag")
+ cronjobUpdateCmd.Flags().StringVarP(&cronjobImageName, "image-name", "", "", "Cronjob Image Name")
+}
diff --git a/cmd/database.go b/cmd/database.go
new file mode 100644
index 00000000..e413aa75
--- /dev/null
+++ b/cmd/database.go
@@ -0,0 +1,29 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var databaseName string
+var databaseNames string
+var showCredentials bool
+var databaseCmd = &cobra.Command{
+ Use: "database",
+ Short: "Manage databases",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(databaseCmd)
+}
diff --git a/cmd/database_cancel.go b/cmd/database_cancel.go
new file mode 100644
index 00000000..f6840766
--- /dev/null
+++ b/cmd/database_cancel.go
@@ -0,0 +1,20 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var databaseCancelCmd = &cobra.Command{
+ Use: "cancel",
+ Short: "Cancel a database deployment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ utils.PrintlnInfo("Use: 'qovery environment cancel' to cancel this deployment")
+ },
+}
+
+func init() {
+ databaseCmd.AddCommand(databaseCancelCmd)
+}
diff --git a/cmd/database_delete.go b/cmd/database_delete.go
new file mode 100644
index 00000000..6f0d7dcd
--- /dev/null
+++ b/cmd/database_delete.go
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var databaseDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete a database",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateDatabaseArguments(databaseName, databaseNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ databaseList := buildDatabaseListFromDatabaseNames(client, envId, databaseName, databaseNames)
+ _, err := client.EnvironmentActionsAPI.
+ DeleteSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ DatabaseIds: utils.Map(databaseList, func(database *qovery.Database) string {
+ return database.Id
+ }),
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to delete database(s) %s has been queued...", pterm.FgBlue.Sprintf("%s%s", databaseName, databaseNames)))
+ WatchDatabaseDeployment(client, envId, databaseList, watchFlag, qovery.STATEENUM_DELETED)
+ },
+}
+
+func init() {
+ databaseCmd.AddCommand(databaseDeleteCmd)
+ databaseDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ databaseDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ databaseDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ databaseDeleteCmd.Flags().StringVarP(&databaseName, "database", "n", "", "Database Name")
+ databaseDeleteCmd.Flags().StringVarP(&databaseNames, "databases", "", "", "Database Names (comma separated) Example: --databases \"db1,db2\"")
+ databaseDeleteCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch database status until it's ready or an error occurs")
+}
diff --git a/cmd/database_deploy.go b/cmd/database_deploy.go
new file mode 100644
index 00000000..dcb1ae8b
--- /dev/null
+++ b/cmd/database_deploy.go
@@ -0,0 +1,57 @@
+package cmd
+
+import (
+ "fmt"
+ "github.com/qovery/qovery-client-go"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var databaseDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy a database",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateDatabaseArguments(databaseName, databaseNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ databaseList := buildDatabaseListFromDatabaseNames(client, envId, databaseName, databaseNames)
+ err := utils.DeployDatabases(client, envId, databaseList)
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to deploy database(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", databaseName, databaseNames)))
+ WatchDatabaseDeployment(client, envId, databaseList, watchFlag, qovery.STATEENUM_DEPLOYED)
+ },
+}
+
+func WatchDatabaseDeployment(
+ client *qovery.APIClient,
+ envId string,
+ databaseList []*qovery.Database,
+ watchFlag bool,
+ finalServiceState qovery.StateEnum,
+) {
+ if watchFlag {
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent from race condition)
+ if len(databaseList) == 1 {
+ utils.WatchDatabase(databaseList[0].Id, envId, client)
+ } else {
+ utils.WatchEnvironment(envId, finalServiceState, client)
+ }
+ }
+}
+
+func init() {
+ databaseCmd.AddCommand(databaseDeployCmd)
+ databaseDeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ databaseDeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ databaseDeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ databaseDeployCmd.Flags().StringVarP(&databaseName, "database", "n", "", "Database Name")
+ databaseDeployCmd.Flags().StringVarP(&databaseNames, "databases", "", "", "Database Names (comma separated) (ex: --databases \"database1,database2\")")
+ databaseDeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch database status until it's ready or an error occurs")
+}
diff --git a/cmd/database_list.go b/cmd/database_list.go
new file mode 100644
index 00000000..192dc64f
--- /dev/null
+++ b/cmd/database_list.go
@@ -0,0 +1,138 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/qovery/qovery-client-go"
+ "os"
+ "strconv"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var databaseListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List databases",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ databases, _, err := client.DatabasesAPI.ListDatabase(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(getDatabaseJsonOutput(*client, statuses.GetDatabases(), databases.GetResults()))
+ return
+ }
+
+ var data [][]string
+
+ for _, database := range databases.GetResults() {
+ res, _, err := client.DatabaseMainCallsAPI.GetDatabaseMasterCredentials(context.Background(), database.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ login := "********"
+ password := "********"
+
+ if showCredentials {
+ login = res.Login
+
+ if login == "" {
+ login = "N/A"
+ }
+
+ password = res.Password
+ }
+
+ data = append(data, []string{database.Id, database.Name, "Database",
+ utils.FindStatusTextWithColor(statuses.GetDatabases(), database.Id), res.Host, strconv.Itoa(int(res.Port)), login, password, database.UpdatedAt.String()})
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name", "Type", "Status", "Host", "Port", "Login", "Password", "Last Update"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getDatabaseJsonOutput(client qovery.APIClient, statuses []qovery.Status, databases []qovery.Database) string {
+ var results []interface{}
+
+ for _, database := range databases {
+ res, _, err := client.DatabaseMainCallsAPI.GetDatabaseMasterCredentials(context.Background(), database.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ results = append(results, map[string]interface{}{
+ "id": database.Id,
+ "updated_at": utils.ToIso8601(database.UpdatedAt),
+ "name": database.Name,
+ "type": "Database",
+ "database_type": database.Type,
+ "status": utils.FindStatus(statuses, database.Id),
+ "host": database.Host,
+ "port": res.Port,
+ "login": res.Login,
+ "password": res.Password,
+ })
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ databaseCmd.AddCommand(databaseListCmd)
+ databaseListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ databaseListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ databaseListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ databaseListCmd.Flags().BoolVarP(&showCredentials, "show-credentials", "", false, "Show Credentials")
+ databaseListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/database_redeploy.go b/cmd/database_redeploy.go
new file mode 100644
index 00000000..8d4625d3
--- /dev/null
+++ b/cmd/database_redeploy.go
@@ -0,0 +1,42 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var databaseRedeployCmd = &cobra.Command{
+ Use: "redeploy",
+ Short: "Redeploy a database",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateDatabaseArguments(databaseName, databaseNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ databaseList := buildDatabaseListFromDatabaseNames(client, envId, databaseName, databaseNames)
+ _, _, err := client.DatabaseActionsAPI.
+ DeployDatabase(context.Background(), databaseList[0].Id).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to redeploy database(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", databaseName, databaseNames)))
+ WatchDatabaseDeployment(client, envId, databaseList, watchFlag, qovery.STATEENUM_RESTARTED)
+ },
+}
+
+func init() {
+ databaseCmd.AddCommand(databaseRedeployCmd)
+ databaseRedeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ databaseRedeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ databaseRedeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ databaseRedeployCmd.Flags().StringVarP(&databaseName, "database", "n", "", "Database Name")
+ databaseRedeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch database status until it's ready or an error occurs")
+
+ _ = databaseRedeployCmd.MarkFlagRequired("database")
+}
diff --git a/cmd/database_stop.go b/cmd/database_stop.go
new file mode 100644
index 00000000..1f795fbe
--- /dev/null
+++ b/cmd/database_stop.go
@@ -0,0 +1,99 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+ "strings"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var databaseStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop a database",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateDatabaseArguments(databaseName, databaseNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ applicationList := buildDatabaseListFromDatabaseNames(client, envId, databaseName, databaseNames)
+ _, err := client.EnvironmentActionsAPI.
+ StopSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ DatabaseIds: utils.Map(applicationList, func(database *qovery.Database) string {
+ return database.Id
+ }),
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to stop databases %s has been queued...", pterm.FgBlue.Sprintf("%s%s", databaseName, databaseNames)))
+ WatchDatabaseDeployment(client, envId, applicationList, watchFlag, qovery.STATEENUM_STOPPED)
+ },
+}
+
+func buildDatabaseListFromDatabaseNames(
+ client *qovery.APIClient,
+ environmentId string,
+ databaseName string,
+ databaseNames string,
+) []*qovery.Database {
+ var databaseList []*qovery.Database
+ databases, _, err := client.DatabasesAPI.ListDatabase(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ if databaseName != "" {
+ database := utils.FindByDatabaseName(databases.GetResults(), databaseName)
+ if database == nil {
+ utils.PrintlnError(fmt.Errorf("database %s not found", databaseName))
+ utils.PrintlnInfo("You can list all databases with: qovery database list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ databaseList = append(databaseList, database)
+ }
+ if databaseNames != "" {
+ for _, databaseName := range strings.Split(databaseNames, ",") {
+ trimmedDatabaseName := strings.TrimSpace(databaseName)
+ database := utils.FindByDatabaseName(databases.GetResults(), trimmedDatabaseName)
+ if database == nil {
+ utils.PrintlnError(fmt.Errorf("database %s not found", databaseName))
+ utils.PrintlnInfo("You can list all databases with: qovery database list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ databaseList = append(databaseList, database)
+ }
+ }
+
+ return databaseList
+}
+
+func validateDatabaseArguments(databaseName string, databaseNames string) {
+ if databaseName == "" && databaseNames == "" {
+ utils.PrintlnError(fmt.Errorf("use either --database \"\" or --databases \", \" but not both at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if databaseName != "" && databaseNames != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --database and --databases at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
+
+func init() {
+ databaseCmd.AddCommand(databaseStopCmd)
+ databaseStopCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ databaseStopCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ databaseStopCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ databaseStopCmd.Flags().StringVarP(&databaseName, "database", "n", "", "Database Name")
+ databaseStopCmd.Flags().StringVarP(&databaseNames, "databases", "", "", "Database Names (comma separated) Example: --databases \"db1,db2\"")
+ databaseStopCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch database status until it's ready or an error occurs")
+}
diff --git a/cmd/demo.go b/cmd/demo.go
new file mode 100644
index 00000000..f3461f34
--- /dev/null
+++ b/cmd/demo.go
@@ -0,0 +1,108 @@
+package cmd
+
+import (
+ _ "embed"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+// writeDemoTokenFile keeps the authorization header out of script and curl
+// arguments, including shell debug traces. The caller must remove the file.
+func writeDemoTokenFile(dir string, tokenType utils.AccessTokenType, token utils.AccessToken) (string, error) {
+ dir, err := filepath.Abs(dir)
+ if err != nil {
+ return "", fmt.Errorf("resolve demo token directory: %w", err)
+ }
+ file, err := os.CreateTemp(dir, "qovery-token-*")
+ if err != nil {
+ return "", fmt.Errorf("create demo token file: %w", err)
+ }
+
+ _, writeErr := fmt.Fprintln(file, "Authorization: "+utils.GetAuthorizationHeaderValue(tokenType, token))
+ closeErr := file.Close()
+ if writeErr != nil {
+ _ = os.Remove(file.Name())
+ return "", fmt.Errorf("write demo token file: %w", writeErr)
+ }
+ if closeErr != nil {
+ _ = os.Remove(file.Name())
+ return "", fmt.Errorf("close demo token file: %w", closeErr)
+ }
+ return file.Name(), nil
+}
+
+// prepareDemoTokenFile holds a directory lock until cleanup, so stale token
+// files can be removed without deleting another running demo's credentials.
+func prepareDemoTokenFile(dir string, tokenType utils.AccessTokenType, token utils.AccessToken) (string, func(), error) {
+ lock, err := lockDemoTokenDirectory(dir)
+ if err != nil {
+ return "", nil, err
+ }
+ entries, err := os.ReadDir(dir)
+ if err == nil {
+ for _, entry := range entries {
+ if entry.Type().IsRegular() && strings.HasPrefix(entry.Name(), "qovery-token-") {
+ if err = os.Remove(filepath.Join(dir, entry.Name())); err != nil {
+ break
+ }
+ }
+ }
+ }
+ if err != nil {
+ _ = lock.Close()
+ return "", nil, fmt.Errorf("remove stale demo token files: %w", err)
+ }
+
+ path, err := writeDemoTokenFile(dir, tokenType, token)
+ if err != nil {
+ _ = lock.Close()
+ return "", nil, err
+ }
+ var once sync.Once
+ cleanup := func() {
+ once.Do(func() {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ utils.PrintlnError(fmt.Errorf("cannot remove demo token file: %w", err))
+ }
+ _ = lock.Close()
+ })
+ }
+ return path, cleanup, nil
+}
+
+var (
+ demoClusterName string
+ demoDeleteQoveryConfig bool
+ demoDebug bool
+ demoChartPath string
+ demoEngineImage string
+)
+
+//go:embed demo_scripts/create_qovery_demo.sh
+var demoScriptsCreate []byte
+
+//go:embed demo_scripts/destroy_qovery_demo.sh
+var demoScriptsDestroy []byte
+
+var demoCmd = &cobra.Command{
+ Use: "demo",
+ Short: "Try Qovery on your local machine",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(demoCmd)
+}
diff --git a/cmd/demo_destroy.go b/cmd/demo_destroy.go
new file mode 100644
index 00000000..66e660f1
--- /dev/null
+++ b/cmd/demo_destroy.go
@@ -0,0 +1,93 @@
+package cmd
+
+import (
+ _ "embed"
+ "fmt"
+ "github.com/spf13/cobra"
+ "os"
+ "os/exec"
+ "os/signal"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "syscall"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var demoDestroyCmd = &cobra.Command{
+ Use: "destroy",
+ Short: "Remove k3s cluster with Qovery installed on your local machine",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ orgId, _, err := utils.CurrentOrganization(true)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("cannot get Bearer or Token to access Qovery API. Please use `qovery auth` first: %s", err))
+ os.Exit(1)
+ }
+
+ scriptDir := filepath.Join(os.TempDir(), "qovery-demo")
+ mErr := os.MkdirAll(scriptDir, os.FileMode(0700))
+ if mErr != nil {
+ utils.PrintlnError(mErr)
+ os.Exit(1)
+ }
+
+ scriptPath := filepath.Join(scriptDir, "destroy_demo_cluster.sh")
+ err = os.WriteFile(scriptPath, demoScriptsCreate, 0700)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("cannot write file to disk: %s", err))
+ os.Exit(1)
+ }
+ err = os.WriteFile(scriptPath, demoScriptsDestroy, 0700)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("cannot write file to disk: %s", err))
+ os.Exit(1)
+ }
+
+ ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ tokenPath, cleanupToken, err := prepareDemoTokenFile(scriptDir, tokenType, token)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ defer cleanupToken()
+
+ shCmd := exec.CommandContext(ctx, "/bin/sh", scriptPath, demoClusterName, string(orgId), tokenPath, strconv.FormatBool(demoDeleteQoveryConfig))
+ shCmd.Stdout = os.Stdout
+ shCmd.Stderr = os.Stderr
+ err = shCmd.Run()
+ cleanupToken()
+ stop()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("error executing the command %s", err))
+ utils.CaptureError(cmd, shCmd.String(), err.Error())
+ }
+ utils.CaptureWithEvent(cmd, utils.EndOfExecutionEventName)
+ },
+}
+
+func init() {
+ var userName string
+ currentUser, err := user.Current()
+ if err != nil {
+ userName = "qovery"
+ } else {
+ userName = currentUser.Username
+ }
+
+ var demoDestroyCmd = demoDestroyCmd
+ demoDestroyCmd.Flags().StringVarP(&demoClusterName, "cluster-name", "c", "local-demo-"+userName, "The name of the cluster to destroy")
+ demoDestroyCmd.Flags().BoolVarP(&demoDeleteQoveryConfig, "delete-qovery-config", "d", false, "Delete the config on Qovery side as well (environments and associated cluster)")
+
+ demoCmd.AddCommand(demoDestroyCmd)
+}
diff --git a/cmd/demo_process_unix_test.go b/cmd/demo_process_unix_test.go
new file mode 100644
index 00000000..62773109
--- /dev/null
+++ b/cmd/demo_process_unix_test.go
@@ -0,0 +1,154 @@
+//go:build unix
+
+package cmd
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+func TestPrepareDemoTokenFileCleanup(t *testing.T) {
+ dir := t.TempDir()
+ stalePath := filepath.Join(dir, "qovery-token-stale")
+ logPath := filepath.Join(dir, "qovery-demo.log")
+ for _, path := range []string{stalePath, logPath} {
+ if err := os.WriteFile(path, []byte("test data"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ path, cleanup, err := prepareDemoTokenFile(dir, "Bearer", "test-token")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cleanup()
+ if _, err := os.Stat(stalePath); !os.IsNotExist(err) {
+ t.Fatalf("stale token was not removed: %v", err)
+ }
+ if _, err := os.Stat(logPath); err != nil {
+ t.Fatalf("demo log must be preserved: %v", err)
+ }
+
+ // A second invocation must not delete a token that is still in use.
+ _, secondCleanup, err := prepareDemoTokenFile(dir, "Token", "qov_other-token")
+ if err == nil {
+ secondCleanup()
+ t.Fatal("expected the active demo to retain the directory lock")
+ }
+ if data, err := os.ReadFile(path); err != nil || string(data) != "Authorization: Bearer test-token\n" {
+ t.Fatalf("active demo token was changed or removed: %q, %v", data, err)
+ }
+ cleanup()
+ cleanup() // Cleanup is also deferred by the caller.
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("token was not removed: %v", err)
+ }
+ _, nextCleanup, err := prepareDemoTokenFile(dir, "Token", "qov_next-token")
+ if err != nil {
+ t.Fatalf("directory lock was not released: %v", err)
+ }
+ nextCleanup()
+}
+
+func TestDemoTokenCleanupOnSignal(t *testing.T) {
+ for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL} {
+ t.Run(sig.String(), func(t *testing.T) {
+ dir := t.TempDir()
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ helper := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestDemoTokenSignalHelper$")
+ helper.WaitDelay = time.Second
+ helper.Env = append(os.Environ(), "QOVERY_TEST_DEMO_TOKEN_DIR="+dir)
+ var stderr bytes.Buffer
+ helper.Stderr = &stderr
+ stdout, err := helper.StdoutPipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := helper.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = helper.Process.Kill() }()
+ // The child announces readiness only after the token exists and the
+ // shell process group has started. Never signal the test runner.
+ scanner := bufio.NewScanner(stdout)
+ if !scanner.Scan() {
+ _ = helper.Wait()
+ t.Fatalf("helper did not start: %v, %s", scanner.Err(), stderr.String())
+ }
+ pidText, found := strings.CutPrefix(scanner.Text(), "ready ")
+ if !found {
+ t.Fatalf("unexpected helper output: %q", scanner.Text())
+ }
+ childPID, err := strconv.Atoi(pidText)
+ if err != nil || childPID <= 0 {
+ t.Fatalf("invalid child PID %q: %v", pidText, err)
+ }
+ // SIGKILL cannot be handled by the helper, so its child must be
+ // stopped by the parent test in that case.
+ defer func() { _ = unix.Kill(-childPID, unix.SIGKILL) }()
+ paths, err := filepath.Glob(filepath.Join(dir, "qovery-token-*"))
+ if err != nil || len(paths) != 1 {
+ t.Fatalf("expected one active token file: %v, %v", paths, err)
+ }
+ if err := helper.Process.Signal(sig); err != nil {
+ t.Fatal(err)
+ }
+ err = helper.Wait()
+ if ctx.Err() != nil {
+ t.Fatalf("helper did not terminate after %v: %s", sig, stderr.String())
+ }
+ if sig == syscall.SIGKILL {
+ if err == nil {
+ t.Fatal("expected helper to be killed")
+ }
+ if _, err := os.Stat(paths[0]); err != nil {
+ t.Fatalf("expected leftover token after SIGKILL: %v", err)
+ }
+ _, cleanup, err := prepareDemoTokenFile(dir, "Bearer", "next-token")
+ if err != nil {
+ t.Fatalf("could not recover after SIGKILL: %v", err)
+ }
+ cleanup()
+ } else if err != nil {
+ t.Fatalf("helper did not cleanly handle %v: %v, %s", sig, err, stderr.String())
+ }
+ if _, err := os.Stat(paths[0]); !os.IsNotExist(err) {
+ t.Fatalf("token file remains after %v: %v", sig, err)
+ }
+ })
+ }
+}
+
+// Run in a separate process so real signals cannot interrupt other tests.
+func TestDemoTokenSignalHelper(t *testing.T) {
+ dir := os.Getenv("QOVERY_TEST_DEMO_TOKEN_DIR")
+ if dir == "" {
+ return
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ _, cleanup, err := prepareDemoTokenFile(dir, "Bearer", "test-token")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cleanup()
+ command := exec.CommandContext(ctx, "/bin/sh", "-c", `printf 'ready %s\n' "$$"; exec sleep 60`)
+ command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ command.Stdout = os.Stdout
+ command.Stderr = os.Stderr
+ if err := command.Run(); ctx.Err() == nil || err == nil {
+ t.Fatalf("expected command to stop on cancellation: %v", err)
+ }
+}
diff --git a/cmd/demo_scripts/create_qovery_demo.sh b/cmd/demo_scripts/create_qovery_demo.sh
new file mode 100755
index 00000000..41ff15f8
--- /dev/null
+++ b/cmd/demo_scripts/create_qovery_demo.sh
@@ -0,0 +1,347 @@
+#!/usr/bin/env bash
+
+set -eu
+
+QOVERY_API_URL=${QOVERY_API_URL:='https://api.qovery.com'}
+CLUSTER_NAME=$1
+ARCH=$2
+ORGANIZATION_ID=$3
+AUTHORIZATION_HEADER_FILE=$4
+USER_AGENT=$6
+case $5 in
+ true)
+ set -x
+ HELM_DEBUG="--debug"
+ ;;
+
+ *)
+ HELM_DEBUG=""
+ ;;
+esac
+
+POWERSHELL_CMD='powershell.exe'
+
+get_or_create_on_premise_account() {
+ accountId=$(curl -s --fail-with-body -H "@${AUTHORIZATION_HEADER_FILE}" -H 'Content-Type: application/json' -H "User-Agent: ${USER_AGENT}" ${QOVERY_API_URL}/organization/"${ORGANIZATION_ID}"/onPremise/credentials | jq -r .results[0].id)
+ if [ "$accountId" = "null" ]
+ then
+ accountId=$(curl -s -X POST --fail-with-body -H "@${AUTHORIZATION_HEADER_FILE}" -H 'Content-Type: application/json' -H "User-Agent: ${USER_AGENT}" -d '{"name": "on-premise"}' ${QOVERY_API_URL}/organization/"${ORGANIZATION_ID}"/onPremise/credentials | jq -r .id)
+ fi
+
+ echo "$accountId"
+}
+
+get_or_create_demo_cluster() {
+ accountId=$1
+ clusterName=$2
+ clusterId=$(curl -s -X GET --fail-with-body -H "@${AUTHORIZATION_HEADER_FILE}" -H 'Content-Type: application/json' -H "User-Agent: ${USER_AGENT}" ${QOVERY_API_URL}/organization/"${ORGANIZATION_ID}"/cluster | jq -r '.results[] | select(.name=="'"$clusterName"'") | .id')
+
+ if [ "$clusterId" = "" ]
+ then
+ payload='{"name":"'$2'","region":"on-premise","cloud_provider":"ON_PREMISE","kubernetes":"SELF_MANAGED", "production": false, "is_demo": true, "features":[],"cloud_provider_credentials":{"cloud_provider":"ON_PREMISE","credentials":{"id":"'${accountId}'","name":"on-premise"},"region":"unknown"}}'
+ clusterId=$(curl -s -X POST --fail-with-body -H "@${AUTHORIZATION_HEADER_FILE}" -H 'Content-Type: application/json' -H "User-Agent: ${USER_AGENT}" -d "${payload}" ${QOVERY_API_URL}/organization/"${ORGANIZATION_ID}"/cluster | jq -r .id)
+ fi
+
+ echo "$clusterId"
+}
+
+get_cluster_values() {
+ clusterId=$1
+ curl -s -X GET --fail-with-body -H "@${AUTHORIZATION_HEADER_FILE}" -H 'Content-Type: application/x-yaml' -H "User-Agent: ${USER_AGENT}" ${QOVERY_API_URL}/organization/"${ORGANIZATION_ID}"/cluster/"${clusterId}"/installationHelmValues
+}
+
+get_or_create_cluster() {
+ clusterName=$1
+ clusterExist=$(k3d cluster list -o json | jq '.[] | select(.name=="'"$clusterName"'") | .name')
+ if [ "$clusterExist" = "" ]
+ then
+ k3d cluster create "$clusterName" \
+ --image 'docker.io/rancher/k3s:v1.36.4-k3s1' \
+ --subnet '172.42.0.0/16' \
+ --k3s-arg "--node-ip=172.42.0.3@server:0" \
+ --k3s-arg "--disable=traefik@server:*" \
+ --registry-create qovery-registry.lan \
+ --port "80:80@loadbalancer" --port "443:443@loadbalancer"
+ else
+ k3d cluster start "$clusterName"
+ fi
+}
+
+install_or_upgrade_helm_charts() {
+ local chart_source="qovery/qovery"
+ local helm_values_args=(-f values.yaml)
+ local engine_image_overrides=()
+
+ if [ -n "${QOVERY_DEMO_CHART_PATH:-}" ]; then
+ chart_source="${QOVERY_DEMO_CHART_PATH}"
+ helm dependency update "${chart_source}"
+ fi
+
+ if [ -n "${QOVERY_DEMO_ENGINE_IMAGE_REPOSITORY:-}" ]; then
+ engine_image_overrides=(
+ --set-string "qovery-engine.image.repository=${QOVERY_DEMO_ENGINE_IMAGE_REPOSITORY}"
+ --set-string "qovery-engine.image.tag=${QOVERY_DEMO_ENGINE_IMAGE_TAG}"
+ --set "qovery-engine.image.pullPolicy=IfNotPresent"
+ )
+ local source_engine_image="${QOVERY_DEMO_ENGINE_IMAGE_SOURCE:-${QOVERY_DEMO_ENGINE_IMAGE_REPOSITORY}:${QOVERY_DEMO_ENGINE_IMAGE_TAG}}"
+ local engine_image="${QOVERY_DEMO_ENGINE_IMAGE_REPOSITORY}:${QOVERY_DEMO_ENGINE_IMAGE_TAG}"
+ local server_node="k3d-${CLUSTER_NAME}-server-0"
+ local imported_image_id
+ imported_image_id=$(docker exec "${server_node}" crictl images -q "${engine_image}" 2>/dev/null || true)
+
+ if [ -z "${imported_image_id}" ]; then
+ if [ "${source_engine_image}" != "${engine_image}" ]; then
+ docker tag "${source_engine_image}" "${engine_image}"
+ fi
+ k3d image import "${engine_image}" --cluster "${CLUSTER_NAME}"
+ else
+ echo "Engine image ${engine_image} is already available in ${server_node}"
+ fi
+ fi
+
+ # Gateway API and Envoy custom resources must exist before Helm renders
+ # the charts that create Gateway, HTTPRoute and EnvoyProxy objects.
+ #
+ # Use helm template + kubectl apply --server-side instead of a Helm release:
+ # the CRD bundle is too large to fit in Helm's release Secret storage.
+ set -x
+ local crd_chart_path="$chart_source/charts/envoy-gateway-crd"
+ if [ "$chart_source" = "qovery/qovery" ]; then
+ local tmp_chart_dir
+ local extracted_chart_dir
+ local extracted_charts_dir
+ tmp_chart_dir=$(mktemp -d)
+ helm pull "$chart_source" --untar --untardir "$tmp_chart_dir"
+ extracted_chart_dir=$(find "$tmp_chart_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)
+ if [ -z "$extracted_chart_dir" ]; then
+ echo "Unable to locate extracted chart directory under $tmp_chart_dir"
+ exit 1
+ fi
+ extracted_charts_dir="$extracted_chart_dir/charts"
+ crd_chart_path=$(find "$extracted_charts_dir" -maxdepth 1 \
+ \( -type d -name 'gateway-crds-helm' -o -type d -name 'envoy-gateway-crd' -o -type f -name '*gateway*crd*.tgz' \) | head -n 1)
+ if [ -z "$crd_chart_path" ]; then
+ echo "Unable to locate Envoy Gateway CRD chart under $extracted_charts_dir"
+ ls -la "$extracted_charts_dir"
+ exit 1
+ fi
+ fi
+
+ helm template qovery-gateway-crds "$crd_chart_path" \
+ --set crds.gatewayAPI.enabled=true \
+ --set crds.gatewayAPI.channel=standard \
+ --set crds.envoyGateway.enabled=true | kubectl apply --server-side -f -
+ kubectl wait --for=condition=Established --timeout=180s crd/gateways.gateway.networking.k8s.io
+ kubectl wait --for=condition=Established --timeout=180s crd/envoyproxies.gateway.envoyproxy.io
+ set +x
+
+ releaseExist=$(helm list -n qovery -o json | jq '.[] | select(.name=="qovery") | .name')
+ if [ "$releaseExist" = "" ]
+ then
+ set -x
+ helm upgrade --install --create-namespace ${HELM_DEBUG} --timeout=15m -n qovery "${helm_values_args[@]}" --atomic \
+ --set services.certificates.cert-manager-configs.enabled=false \
+ --set services.certificates.qovery-cert-manager-webhook.enabled=false \
+ --set services.ingress.envoy-gateway-crd.enabled=false \
+ --set services.qovery.qovery-cluster-agent.enabled=false \
+ --set services.qovery.qovery-engine.enabled=false \
+ --set services.qovery.qovery-operator.enabled=false \
+ "${engine_image_overrides[@]+"${engine_image_overrides[@]}"}" qovery "$chart_source"
+ fi
+
+ for i in $(seq 1 3); do
+ set -x
+ helm upgrade --install --create-namespace ${HELM_DEBUG} --timeout=15m -n qovery "${helm_values_args[@]}" --wait --atomic \
+ --set services.ingress.envoy-gateway-crd.enabled=false \
+ --set services.qovery.qovery-operator.enabled=false \
+ "${engine_image_overrides[@]+"${engine_image_overrides[@]}"}" qovery "$chart_source" && break
+ set +x
+ echo "Install failed. Retrying in 10 seconds. To let the cluster initialize"
+ sleep 10
+ done
+
+ set +x
+}
+
+setup_network() {
+ if [ "$(uname -s)" = 'Darwin' ]; then
+ # MacOs
+ set -x
+ sudo ifconfig lo0 alias 172.42.0.3/32 up || true
+ elif grep -qi microsoft /proc/version; then
+ # Wsl
+ set -x
+ sudo ip addr add 172.42.0.3/32 dev lo || true
+ ${POWERSHELL_CMD} -Command "Start-Process powershell -Verb RunAs -ArgumentList \"netsh interface ipv4 add address name='Loopback Pseudo-Interface 1' address=172.42.0.3 mask=255.255.255.255 skipassource=true\""
+ fi
+ set +x
+}
+
+try_install_missing_deps() {
+ if which sudo >/dev/null; then
+ SUDO="sudo"
+ else
+ SUDO=""
+ fi
+
+ if which apt-get >/dev/null; then
+ echo "Installing dependencies with apt"
+ ${SUDO} apt-get update && ${SUDO} apt-get install -y jq grep sed curl iproute2
+ elif which yum >/dev/null; then
+ echo "Installing dependencies with yum"
+ ${SUDO} yum update -y && ${SUDO} yum install -y jq grep sed curl iproute
+ elif which pacman >/dev/null; then
+ echo "Installing dependencies with pacman"
+ ${SUDO} pacman -Sy && ${SUDO} pacman --noconfirm -S jq grep curl sed iproute
+ elif which brew >/dev/null; then
+ echo "Installing dependencies with brew"
+ brew update && brew install jq grep curl
+ else
+ echo "Cannot detect your package manager. Please install the following command 'jq grep curl sed iproute2'"
+ exit 1
+ fi
+}
+
+install_deps() {
+ if which jq >/dev/null; then
+ echo "jq already installed"
+ else
+ try_install_missing_deps
+ fi
+
+ if which grep >/dev/null; then
+ echo "grep already installed"
+ else
+ try_install_missing_deps
+ fi
+
+ if which sed >/dev/null; then
+ echo "sed already installed"
+ else
+ try_install_missing_deps
+ fi
+
+ if test -f /proc/version && grep -qi microsoft /proc/version; then
+ if which ip >/dev/null; then
+ echo "iproute already installed"
+ else
+ try_install_missing_deps
+ fi
+ fi
+
+ if which curl >/dev/null; then
+ echo "curl already installed"
+ else
+ try_install_missing_deps
+ fi
+
+ if which docker >/dev/null; then
+ echo "docker already installed"
+ else
+ echo "docker command is missing. Please use your package manager to install it"
+ echo "https://docs.docker.com/engine/install/"
+ exit 1
+ fi
+
+ docker_running=$( (docker ps -q >/dev/null && echo true ) || echo false )
+ if "$docker_running" == "true"; then
+ echo "docker is running"
+ else
+ echo "Docker is not running. Please start Docker before running this command"
+ exit 1
+ fi
+
+ if which k3d >/dev/null; then
+ echo "k3d already installed"
+ else
+ echo "Installing k3d"
+ curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG=v5.8.3 bash
+ fi
+
+ if which helm >/dev/null; then
+ echo "helm already installed"
+ else
+ echo "Installing HELM"
+ curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
+ fi
+
+ # Wsl powershell
+ if test -f /proc/version && grep -qi microsoft /proc/version; then
+ if which 'powershell.exe' >/dev/null; then
+ echo "powershell is installed"
+ POWERSHELL_CMD='powershell.exe'
+ elif which '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe' >/dev/null; then
+ echo "powershell is installed"
+ POWERSHELL_CMD='/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
+ else
+ echo "Cannot find powershell.exe, please be sure it is installed"
+ exit 1
+ fi
+ fi
+
+ echo "All dependencies are installed"
+}
+
+# shellcheck disable=SC2046
+# shellcheck disable=SC2086
+cd "$(dirname $(realpath $0))"
+
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo 'Checking and installing dependencies'
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+install_deps
+
+accountId=$(get_or_create_on_premise_account)
+clusterId=$(get_or_create_demo_cluster "${accountId}" "${CLUSTER_NAME}")
+
+echo ''
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo 'Fetching Qovery values to setup your cluster'
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+get_cluster_values "${clusterId}" > values.yaml
+echo "" >> values.yaml
+sed -i.bak 's/AMD64/'"$ARCH"'/g' values.yaml
+rm values.yaml.bak
+if [ -n "${QOVERY_DEMO_CHART_PATH:-}" ]; then
+ grep -vE 'set-by-customer|^qovery:' "${QOVERY_DEMO_CHART_PATH}/values-demo-local.yaml" >> values.yaml
+else
+ curl -s -L https://raw.githubusercontent.com/Qovery/qovery-chart/main/charts/qovery/values-demo-local.yaml | grep -vE 'set-by-customer|^qovery:' >> values.yaml
+fi
+echo 'Helm values written into values.yaml'
+
+
+echo ''
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo 'Installing Qovery helm repositories'
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+helm repo add qovery https://helm.qovery.com
+helm repo update qovery
+
+echo ''
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo "Creating $CLUSTER_NAME kube cluster"
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+get_or_create_cluster "$CLUSTER_NAME"
+
+echo ''
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo 'Installing Qovery helm charts'
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+install_or_upgrade_helm_charts
+
+
+echo ''
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo 'Configure network'
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+setup_network
+
+
+echo ''
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo "Qovery demo cluster is now installed !!!!"
+echo "The kubeconfig is correctly set, so you can connect to it directly with kubectl or k9s from your local machine"
+echo "To delete/stop/start your cluster, use k3d cluster xxxx"
+echo ''
+echo "Go to https://console.qovery.com to create your first environment on this cluster '${CLUSTER_NAME}'"
+echo '""""""""""""""""""""""""""""""""""""""""""""'
diff --git a/cmd/demo_scripts/destroy_qovery_demo.sh b/cmd/demo_scripts/destroy_qovery_demo.sh
new file mode 100755
index 00000000..fb935a3f
--- /dev/null
+++ b/cmd/demo_scripts/destroy_qovery_demo.sh
@@ -0,0 +1,95 @@
+#!/bin/sh
+
+set -eu
+
+QOVERY_API_URL=${QOVERY_API_URL:='https://api.qovery.com'}
+CLUSTER_NAME=$1
+ORGANIZATION_ID=$2
+AUTHORIZATION_HEADER_FILE=$3
+DELETE_QOVERY_CONFIG=$4
+
+POWERSHELL_CMD='powershell.exe'
+if test -f /proc/version && grep -qi microsoft /proc/version; then
+ if which 'powershell.exe' >/dev/null; then
+ echo "powershell is installed"
+ POWERSHELL_CMD='powershell.exe'
+ elif which '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe' >/dev/null; then
+ echo "powershell is installed"
+ POWERSHELL_CMD='/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
+ else
+ echo "Cannot find powershell.exe, please be sure it is installed"
+ exit 1
+ fi
+fi
+
+delete_qovery_demo_cluster() {
+ clusterName=$1
+ clusterId=$(curl -s -X GET --fail-with-body -H "@${AUTHORIZATION_HEADER_FILE}" -H 'Content-Type: application/json' ${QOVERY_API_URL}/organization/"${ORGANIZATION_ID}"/cluster | jq -r '.results[] | select(.name=="'"$clusterName"'") | .id')
+
+ if [ -n "$clusterId" ]; then
+ curl -s -X DELETE --fail-with-body -H "@${AUTHORIZATION_HEADER_FILE}" ${QOVERY_API_URL}'/organization/'"${ORGANIZATION_ID}"'/cluster/'"${clusterId}"'?deleteMode=DELETE_QOVERY_CONFIG' || true
+ fi
+}
+
+delete_k3d_cluster() {
+ clusterName=$1
+ clusterExist=$(k3d cluster list -o json | jq '.[] | select(.name=="'"$clusterName"'") | .name')
+ if [ -n "$clusterExist" ]; then
+ k3d cluster delete "$clusterName" || true
+ fi
+ docker network rm "k3d-${clusterName}" >/dev/null 2>&1 || true
+ k3d registry delete qovery-registry.lan >/dev/null 2>&1 || true
+}
+
+teardown_network() {
+ if [ "$(uname -s)" = 'Darwin' ]; then
+ # MacOs
+ set -x
+ sudo ifconfig lo0 -alias 172.42.0.3/32 up || true
+ elif grep -qi microsoft /proc/version; then
+ # Wsl
+ set -x
+ sudo ip addr del 172.42.0.3/32 dev lo || true
+ ${POWERSHELL_CMD} -Command "Start-Process powershell -Verb RunAs -ArgumentList \"netsh interface ipv4 delete address name='Loopback Pseudo-Interface 1' address=172.42.0.3\""
+ fi
+ set +x
+}
+
+# shellcheck disable=SC2046
+# shellcheck disable=SC2086
+cd "$(dirname $(realpath $0))"
+
+echo ''
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo 'Removing Qovery helm repositories'
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+helm repo remove qovery || true
+
+echo ''
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo "Removing $CLUSTER_NAME kube cluster"
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+delete_k3d_cluster "$CLUSTER_NAME"
+
+echo ''
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo 'Removing network config'
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+teardown_network
+
+if [ "$DELETE_QOVERY_CONFIG" = 'true' ]; then
+ echo ''
+ echo '""""""""""""""""""""""""""""""""""""""""""""'
+ echo 'Deleting cluster Qovery side'
+ echo '""""""""""""""""""""""""""""""""""""""""""""'
+ delete_qovery_demo_cluster "$CLUSTER_NAME"
+fi
+
+echo ''
+echo '""""""""""""""""""""""""""""""""""""""""""""'
+echo "Qovery local demo cluster is now deleted !!!"
+if [ "$DELETE_QOVERY_CONFIG" != 'true' ]; then
+ echo "Your created environments still exits !"
+ echo "Go to https://console.qovery.com/organization/${ORGANIZATION_ID}/clusters to delete Qovery cluster config"
+fi
+echo '""""""""""""""""""""""""""""""""""""""""""""'
diff --git a/cmd/demo_token_lock_other.go b/cmd/demo_token_lock_other.go
new file mode 100644
index 00000000..57c44edb
--- /dev/null
+++ b/cmd/demo_token_lock_other.go
@@ -0,0 +1,12 @@
+//go:build !unix
+
+package cmd
+
+import (
+ "fmt"
+ "os"
+)
+
+func lockDemoTokenDirectory(dir string) (*os.File, error) {
+ return nil, fmt.Errorf("qovery demo requires a Unix shell; on Windows, use WSL")
+}
diff --git a/cmd/demo_token_lock_unix.go b/cmd/demo_token_lock_unix.go
new file mode 100644
index 00000000..17065479
--- /dev/null
+++ b/cmd/demo_token_lock_unix.go
@@ -0,0 +1,23 @@
+//go:build unix
+
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "golang.org/x/sys/unix"
+)
+
+func lockDemoTokenDirectory(dir string) (*os.File, error) {
+ file, err := os.OpenFile(filepath.Join(dir, ".qovery-token.lock"), os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ return nil, fmt.Errorf("open demo token lock: %w", err)
+ }
+ if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
+ _ = file.Close()
+ return nil, fmt.Errorf("lock demo token directory (another demo command may be running): %w", err)
+ }
+ return file, nil
+}
diff --git a/cmd/demo_token_test.go b/cmd/demo_token_test.go
new file mode 100644
index 00000000..745be941
--- /dev/null
+++ b/cmd/demo_token_test.go
@@ -0,0 +1,138 @@
+package cmd
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func TestWriteDemoTokenFileWithRelativeDirectory(t *testing.T) {
+ t.Chdir(t.TempDir())
+ if err := os.Mkdir("credentials", 0700); err != nil {
+ t.Fatal(err)
+ }
+ path, err := writeDemoTokenFile("credentials", "Bearer", "test-token")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !filepath.IsAbs(path) {
+ t.Fatalf("token path must be absolute, got %q", path)
+ }
+ // Both demo scripts change directory before making API requests.
+ t.Chdir(t.TempDir())
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "Authorization: Bearer test-token\n" {
+ t.Fatalf("unexpected authorization header: %q", data)
+ }
+}
+
+func TestDemoScriptsReadTokenFile(t *testing.T) {
+ if _, err := exec.LookPath("curl"); err != nil {
+ t.Skip("curl is required to test the demo scripts")
+ }
+
+ for _, tokenType := range []utils.AccessTokenType{"Bearer", "Token"} {
+ for _, command := range []string{"up", "destroy"} {
+ t.Run(string(tokenType)+"/"+command, func(t *testing.T) {
+ token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.test-signature"
+ if tokenType == "Token" {
+ token = "qov_test-static-token-secret"
+ }
+ // Include spaces to exercise quoting of the header file path.
+ dir := filepath.Join(t.TempDir(), "demo credentials")
+ if err := os.Mkdir(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ tokenPath, err := writeDemoTokenFile(dir, tokenType, utils.AccessToken(token))
+ if err != nil {
+ t.Fatal(err)
+ }
+ info, err := os.Stat(tokenPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0600 {
+ t.Fatalf("token file permissions = %o, want 600", info.Mode().Perm())
+ }
+
+ headers := make(chan string, 8)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ headers <- r.Header.Get("Authorization")
+ _, _ = w.Write([]byte("{}"))
+ }))
+ defer server.Close()
+
+ // Run the scripts' real API functions with curl against the local
+ // server, without installing dependencies or modifying a cluster.
+ script := demoScriptsCreate
+ shell := "bash"
+ args := []string{"local-demo", "AMD64", "org-id", tokenPath, "true", "CLI test"}
+ calls := `
+jq() {
+ cat >/dev/null
+ case "$*" in
+ *'.results[0].id') printf 'null\n' ;;
+ *'select('*) ;;
+ *'.id') printf 'test-id\n' ;;
+ esac
+}
+get_or_create_on_premise_account
+get_or_create_demo_cluster test-account "$CLUSTER_NAME"
+get_cluster_values test-cluster
+`
+ wantRequests := 5
+ if command == "destroy" {
+ script = demoScriptsDestroy
+ shell = "sh"
+ args = []string{"local-demo", "org-id", tokenPath, "true"}
+ calls = `
+jq() { cat >/dev/null; printf 'test-cluster\n'; }
+delete_qovery_demo_cluster "$CLUSTER_NAME"
+`
+ wantRequests = 2
+ }
+ if _, err := exec.LookPath(shell); err != nil {
+ t.Skipf("%s is required to test the demo script", shell)
+ }
+ definitions, _, found := strings.Cut(string(script), "# shellcheck disable=SC2046")
+ if !found {
+ t.Fatal("could not separate script definitions from cluster setup")
+ }
+ scriptPath := filepath.Join(dir, "test-demo.sh")
+ if err := os.WriteFile(scriptPath, []byte(definitions+calls), 0700); err != nil {
+ t.Fatal(err)
+ }
+ shCmd := exec.Command(shell, append([]string{"-x", scriptPath}, args...)...)
+ shCmd.Env = append(os.Environ(), "QOVERY_API_URL="+server.URL)
+ if strings.Contains(shCmd.String(), token) {
+ t.Fatal("token leaked into the command line")
+ }
+ output, err := shCmd.CombinedOutput()
+ if bytes.Contains(output, []byte(token)) {
+ t.Fatal("token leaked into script output or debug traces")
+ }
+ if err != nil {
+ t.Fatalf("demo API calls failed: %v\n%s", err, output)
+ }
+ if len(headers) != wantRequests {
+ t.Fatalf("got %d API requests, want %d\n%s", len(headers), wantRequests, output)
+ }
+ for range wantRequests {
+ if got := <-headers; got != string(tokenType)+" "+token {
+ t.Fatalf("API authorization = %q, want the token from the file", got)
+ }
+ }
+ })
+ }
+ }
+}
diff --git a/cmd/demo_up.go b/cmd/demo_up.go
new file mode 100644
index 00000000..eb05c944
--- /dev/null
+++ b/cmd/demo_up.go
@@ -0,0 +1,243 @@
+package cmd
+
+import (
+ "bytes"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "github.com/tonistiigi/go-rosetta"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/signal"
+ "os/user"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+)
+
+var demoUpCmd = &cobra.Command{
+ Use: "up",
+ Short: "Create a k3s kubernetes cluster with Qovery installed on your local machine",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if runtime.GOOS == "windows" {
+ utils.PrintlnError(fmt.Errorf("qovery demo is not supported from Windows. Please use WSL (Windows Subsystem for Linux) to use qovery demo"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ orgId, _, err := utils.CurrentOrganization(true)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("cannot get Bearer or Token to access Qovery API. Please use `qovery auth` first: %s", err))
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ regex := "^[a-zA-Z][-a-z]+[a-zA-Z]$"
+ match, _ := regexp.MatchString(regex, demoClusterName)
+ if !match {
+ utils.PrintlnError(fmt.Errorf("cluster name must match regex %s: got %s", regex, demoClusterName))
+ os.Exit(1)
+ }
+
+ demoChartPath, err = validateDemoChartPath(demoChartPath)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ engineImageRepository, engineImageTag, err := demoEngineImageOverride(demoEngineImage)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ scriptDir := filepath.Join(os.TempDir(), "qovery-demo")
+ mErr := os.MkdirAll(scriptDir, os.FileMode(0700))
+ if mErr != nil {
+ utils.PrintlnError(mErr)
+ os.Exit(1)
+ }
+
+ scriptPath := filepath.Join(scriptDir, "create_demo_cluster.sh")
+ debugLogsPath := filepath.Join(scriptDir, "qovery-demo.log")
+ err = os.WriteFile(scriptPath, demoScriptsCreate, 0700)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("cannot write file to disk: %s", err))
+ os.Exit(1)
+ }
+
+ ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ tokenPath, cleanupToken, err := prepareDemoTokenFile(scriptDir, tokenType, token)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ defer cleanupToken()
+
+ // Pass values as positional parameters so file paths are not interpreted by bash.
+ cmdArgs := `
+set -eu
+set -o pipefail
+"$1" "$2" "$3" "$4" "$5" "$6" "$7" 2>&1 | tee "$8"
+`
+ shCmd := exec.CommandContext(ctx, "/bin/bash", "-c", cmdArgs, "qovery-demo",
+ scriptPath, demoClusterName, detectArchitecture(), string(orgId), tokenPath,
+ strconv.FormatBool(demoDebug), "CLI "+utils.Version, debugLogsPath)
+ shCmd.Env = append(
+ os.Environ(),
+ "QOVERY_DEMO_CHART_PATH="+demoChartPath,
+ "QOVERY_DEMO_ENGINE_IMAGE_SOURCE="+demoEngineImage,
+ "QOVERY_DEMO_ENGINE_IMAGE_REPOSITORY="+engineImageRepository,
+ "QOVERY_DEMO_ENGINE_IMAGE_TAG="+engineImageTag,
+ )
+ shCmd.Stdout = os.Stdout
+ shCmd.Stderr = os.Stderr
+ err = shCmd.Run()
+ cleanupToken()
+ stop()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("error executing the command %s", err))
+ uploadErrorLogs(tokenType, token, orgId, demoClusterName, debugLogsPath)
+ utils.CaptureError(cmd, shCmd.String(), err.Error())
+ }
+
+ utils.CaptureWithEvent(cmd, utils.EndOfExecutionEventName)
+ },
+}
+
+func validateDemoChartPath(chartPath string) (string, error) {
+ if chartPath == "" {
+ return "", nil
+ }
+
+ expandedChartPath, err := expandPath(chartPath)
+ if err != nil {
+ return "", fmt.Errorf("expand demo chart path: %w", err)
+ }
+
+ absChartPath, err := filepath.Abs(expandedChartPath)
+ if err != nil {
+ return "", fmt.Errorf("resolve demo chart path: %w", err)
+ }
+
+ chartInfo, err := os.Stat(absChartPath)
+ if err != nil {
+ return "", fmt.Errorf("read demo chart path %q: %w", absChartPath, err)
+ }
+ if !chartInfo.IsDir() {
+ return "", fmt.Errorf("demo chart path %q must be a directory", absChartPath)
+ }
+
+ for _, filename := range []string{"Chart.yaml", "values-demo-local.yaml"} {
+ if _, err := os.Stat(filepath.Join(absChartPath, filename)); err != nil {
+ return "", fmt.Errorf("demo chart path %q must contain %s: %w", absChartPath, filename, err)
+ }
+ }
+
+ return absChartPath, nil
+}
+
+func demoEngineImageOverride(image string) (string, string, error) {
+ if image == "" {
+ return "", "", nil
+ }
+
+ repository, tag, err := splitImageReference(image)
+ if err != nil {
+ return "", "", fmt.Errorf("invalid demo engine image: %w", err)
+ }
+
+ return normalizeImageRepository(repository), tag, nil
+}
+
+// Only needed due to MacOs when rosetta (x86_64 emulation on ARM64) is turned on.
+// otherwise GOARCH runtime variable is enough to detect the correct arch
+func detectArchitecture() string {
+ if runtime.GOOS != "darwin" {
+ return strings.ToUpper(runtime.GOARCH)
+ }
+
+ return strings.ToUpper(rosetta.NativeArch())
+}
+
+func uploadErrorLogs(tokenType utils.AccessTokenType, token utils.AccessToken, organization utils.Id, clusterName string, debugLogsPath string) {
+ type Payload struct {
+ Organization string `json:"organization"`
+ ClusterName string `json:"cluster_name"`
+ Content string `json:"content"`
+ Os string `json:"os"`
+ CpuArch string `json:"cpu_arch"`
+ CliVersion string `json:"cli_version"`
+ Timestamp time.Time `json:"timestamp"`
+ }
+
+ content, _ := os.ReadFile(debugLogsPath)
+ payload, _ := json.Marshal(Payload{
+ Organization: string(organization),
+ ClusterName: clusterName,
+ Content: string(content),
+ Os: runtime.GOOS,
+ CpuArch: runtime.GOARCH,
+ CliVersion: utils.Version,
+ Timestamp: time.Now(),
+ })
+ client := utils.GetQoveryClient(tokenType, token)
+ url := fmt.Sprintf("%s/admin/demoDebugLog", client.GetConfig().Servers[0].URL)
+ req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
+ query := req.URL.Query()
+ query.Add("organization", string(organization))
+ query.Add("clusterName", clusterName)
+ req.URL.RawQuery = query.Encode()
+
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ response, err := http.DefaultClient.Do(req)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("error uploading debug logs: %s", err))
+ return
+ }
+
+ if response.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(response.Body)
+ utils.PrintlnError(fmt.Errorf("error uploading debug logs: %s %s", response.Status, body))
+ utils.PrintlnInfo("May be caused by a wrong context set, please set it again: `qovery context set`")
+ return
+ }
+}
+
+func init() {
+ var userName string
+ currentUser, err := user.Current()
+ if err != nil {
+ userName = "qovery"
+ } else {
+ userName = currentUser.Username
+ }
+
+ var demoUpCmd = demoUpCmd
+ demoUpCmd.Flags().StringVarP(&demoClusterName, "cluster-name", "c", "local-demo-"+userName, "The name of the cluster to create")
+ demoUpCmd.Flags().BoolVar(&demoDebug, "debug", false, "Enable debug mode")
+ demoUpCmd.Flags().StringVar(&demoChartPath, "chart-path", "", "Local Qovery chart directory to install instead of the published chart")
+ demoUpCmd.Flags().StringVar(&demoEngineImage, "engine-image", "", "Engine image with an explicit tag to use for the demo")
+
+ demoCmd.AddCommand(demoUpCmd)
+}
diff --git a/cmd/demo_up_helpers.go b/cmd/demo_up_helpers.go
new file mode 100644
index 00000000..52573638
--- /dev/null
+++ b/cmd/demo_up_helpers.go
@@ -0,0 +1,77 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func expandPath(path string) (string, error) {
+ if path == "" {
+ return "", nil
+ }
+
+ if path == "~" {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return homeDir, nil
+ }
+
+ if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~"+string(filepath.Separator)) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(homeDir, strings.TrimPrefix(strings.TrimPrefix(path, "~/"), "~"+string(filepath.Separator))), nil
+ }
+
+ return path, nil
+}
+
+func splitImageReference(image string) (string, string, error) {
+ if image != strings.TrimSpace(image) {
+ return "", "", fmt.Errorf("engine image cannot contain surrounding whitespace")
+ }
+ trimmedImage := strings.TrimSpace(image)
+ if trimmedImage == "" {
+ return "", "", fmt.Errorf("engine image cannot be empty")
+ }
+
+ lastSlash := strings.LastIndex(trimmedImage, "/")
+ if strings.Contains(trimmedImage, "@") {
+ return "", "", fmt.Errorf("engine image must include an explicit tag, not a digest: %s", trimmedImage)
+ }
+ lastColon := strings.LastIndex(trimmedImage, ":")
+ if lastColon <= lastSlash {
+ return "", "", fmt.Errorf("engine image must include an explicit tag: got %s", trimmedImage)
+ }
+
+ repository := trimmedImage[:lastColon]
+ tag := trimmedImage[lastColon+1:]
+ if repository == "" || tag == "" {
+ return "", "", fmt.Errorf("invalid engine image reference: %s", trimmedImage)
+ }
+
+ return repository, tag, nil
+}
+
+func normalizeImageRepository(repository string) string {
+ if repository == "" {
+ return repository
+ }
+
+ parts := strings.Split(repository, "/")
+ firstPart := parts[0]
+ if strings.Contains(firstPart, ".") || strings.Contains(firstPart, ":") || firstPart == "localhost" {
+ return repository
+ }
+
+ if len(parts) == 1 {
+ return "docker.io/library/" + repository
+ }
+
+ return "docker.io/" + repository
+}
diff --git a/cmd/demo_up_helpers_test.go b/cmd/demo_up_helpers_test.go
new file mode 100644
index 00000000..a00eee16
--- /dev/null
+++ b/cmd/demo_up_helpers_test.go
@@ -0,0 +1,65 @@
+package cmd
+
+import "testing"
+
+func TestSplitImageReference(t *testing.T) {
+ t.Run("simple image", func(t *testing.T) {
+ repository, tag, err := splitImageReference("qovery-demo-engine:local")
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if repository != "qovery-demo-engine" {
+ t.Fatalf("expected repository qovery-demo-engine, got %s", repository)
+ }
+
+ if tag != "local" {
+ t.Fatalf("expected tag local, got %s", tag)
+ }
+ })
+
+ t.Run("registry with port", func(t *testing.T) {
+ repository, tag, err := splitImageReference("localhost:5001/qovery-demo-engine:dev")
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if repository != "localhost:5001/qovery-demo-engine" {
+ t.Fatalf("expected repository localhost:5001/qovery-demo-engine, got %s", repository)
+ }
+
+ if tag != "dev" {
+ t.Fatalf("expected tag dev, got %s", tag)
+ }
+ })
+
+ t.Run("missing tag", func(t *testing.T) {
+ _, _, err := splitImageReference("qovery-demo-engine")
+ if err == nil {
+ t.Fatal("expected an error for image without tag")
+ }
+ })
+}
+
+func TestNormalizeImageRepository(t *testing.T) {
+ t.Run("short docker hub image", func(t *testing.T) {
+ repository := normalizeImageRepository("qovery-demo-engine")
+ if repository != "docker.io/library/qovery-demo-engine" {
+ t.Fatalf("expected docker.io/library/qovery-demo-engine, got %s", repository)
+ }
+ })
+
+ t.Run("docker hub namespace image", func(t *testing.T) {
+ repository := normalizeImageRepository("qovery/demo-engine")
+ if repository != "docker.io/qovery/demo-engine" {
+ t.Fatalf("expected docker.io/qovery/demo-engine, got %s", repository)
+ }
+ })
+
+ t.Run("explicit registry image", func(t *testing.T) {
+ repository := normalizeImageRepository("ghcr.io/qovery/demo-engine")
+ if repository != "ghcr.io/qovery/demo-engine" {
+ t.Fatalf("expected ghcr.io/qovery/demo-engine, got %s", repository)
+ }
+ })
+}
diff --git a/cmd/demo_up_local_overrides_test.go b/cmd/demo_up_local_overrides_test.go
new file mode 100644
index 00000000..58baff03
--- /dev/null
+++ b/cmd/demo_up_local_overrides_test.go
@@ -0,0 +1,43 @@
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestValidateDemoChartPath(t *testing.T) {
+ chartPath := t.TempDir()
+ for _, filename := range []string{"Chart.yaml", "values-demo-local.yaml"} {
+ if err := os.WriteFile(filepath.Join(chartPath, filename), nil, 0o600); err != nil {
+ t.Fatalf("create %s: %v", filename, err)
+ }
+ }
+
+ validatedPath, err := validateDemoChartPath(chartPath)
+ if err != nil {
+ t.Fatalf("validate demo chart path: %v", err)
+ }
+ if validatedPath != chartPath {
+ t.Fatalf("expected %q, got %q", chartPath, validatedPath)
+ }
+}
+
+func TestDemoEngineImageOverride(t *testing.T) {
+ repository, tag, err := demoEngineImageOverride("qovery-demo-engine:local")
+ if err != nil {
+ t.Fatalf("parse engine image: %v", err)
+ }
+ if repository != "docker.io/library/qovery-demo-engine" || tag != "local" {
+ t.Fatalf("expected docker.io/library/qovery-demo-engine:local, got %s:%s", repository, tag)
+ }
+}
+
+func TestDemoScriptUsesNounsetSafeEmptyEngineImageOverrides(t *testing.T) {
+ const nounsetSafeOverrides = `"${engine_image_overrides[@]+"${engine_image_overrides[@]}"}"`
+
+ if count := strings.Count(string(demoScriptsCreate), nounsetSafeOverrides); count != 2 {
+ t.Fatalf("expected both Helm invocations to use a Bash 3.2 nounset-safe engine image override expansion, found %d", count)
+ }
+}
diff --git a/cmd/enterprise_connection.go b/cmd/enterprise_connection.go
new file mode 100644
index 00000000..39730a58
--- /dev/null
+++ b/cmd/enterprise_connection.go
@@ -0,0 +1,31 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ enterpriseConnectionCmd = &cobra.Command{
+ Use: "enterprise-connection",
+ Short: "Manage enterprise connections",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+ }
+ connectionName string
+ defaultRole string
+ enforceGroupSync bool
+)
+
+func init() {
+ rootCmd.AddCommand(enterpriseConnectionCmd)
+}
diff --git a/cmd/enterprise_connection_get.go b/cmd/enterprise_connection_get.go
new file mode 100644
index 00000000..b1ce6d03
--- /dev/null
+++ b/cmd/enterprise_connection_get.go
@@ -0,0 +1,42 @@
+package cmd
+
+import (
+ "strings"
+
+ "github.com/qovery/qovery-cli/pkg/enterpriseconnection"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ enterpriseConnectionGetCmd = &cobra.Command{
+ Use: "get",
+ Short: "Get enterprise connection information",
+ Run: func(cmd *cobra.Command, args []string) {
+ getEnterpriseConnection()
+ },
+ }
+)
+
+func init() {
+ enterpriseConnectionGetCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ enterpriseConnectionGetCmd.Flags().StringVarP(&connectionName, "connection", "c", "", "Connection Name")
+
+ enterpriseConnectionCmd.AddCommand(enterpriseConnectionGetCmd)
+}
+
+func getEnterpriseConnection() {
+ service, err := enterpriseconnection.NewEnterpriseConnectionService(organizationName)
+ checkError(err)
+
+ enterpriseConnections, err := service.ListEnterpriseConnections(connectionName)
+ checkError(err)
+
+ for i, enterpriseConnection := range enterpriseConnections {
+ if i > 0 {
+ utils.Println("\n" + strings.Repeat("-", 50) + "\n")
+ }
+ err = service.DisplayEnterpriseConnection(&enterpriseConnection)
+ checkError(err)
+ }
+}
diff --git a/cmd/enterprise_connection_group_mappings.go b/cmd/enterprise_connection_group_mappings.go
new file mode 100644
index 00000000..9fe17a51
--- /dev/null
+++ b/cmd/enterprise_connection_group_mappings.go
@@ -0,0 +1,30 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var (
+ enterpriseConnectionGroupMappingsCmd = &cobra.Command{
+ Use: "group-mappings",
+ Short: "Manage enterprise connection group mappings",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+ }
+ qoveryRole string
+ idpGroupNames string
+)
+
+func init() {
+ enterpriseConnectionCmd.AddCommand(enterpriseConnectionGroupMappingsCmd)
+}
diff --git a/cmd/enterprise_connection_group_mappings_add.go b/cmd/enterprise_connection_group_mappings_add.go
new file mode 100644
index 00000000..7839442f
--- /dev/null
+++ b/cmd/enterprise_connection_group_mappings_add.go
@@ -0,0 +1,63 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/qovery/qovery-cli/pkg/enterpriseconnection"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ enterpriseConnectionGroupMappingsAddCmd = &cobra.Command{
+ Use: "add",
+ Short: "Add or modify an enterprise connection group mapping",
+ Run: func(cmd *cobra.Command, args []string) {
+ addEnterpriseConnectionGroupMapping()
+ },
+ }
+)
+
+func init() {
+ enterpriseConnectionGroupMappingsAddCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ enterpriseConnectionGroupMappingsAddCmd.Flags().StringVarP(&connectionName, "connection", "c", "", "Connection Name")
+ enterpriseConnectionGroupMappingsAddCmd.Flags().StringVarP(&qoveryRole, "qovery-role", "q", "", "Qovery role name to target")
+ enterpriseConnectionGroupMappingsAddCmd.Flags().StringVarP(&idpGroupNames, "idp-group-names", "i", "", "Your IDP group names (comma separated)")
+
+ _ = enterpriseConnectionGroupMappingsAddCmd.MarkFlagRequired("connection")
+
+ enterpriseConnectionGroupMappingsCmd.AddCommand(enterpriseConnectionGroupMappingsAddCmd)
+}
+
+func addEnterpriseConnectionGroupMapping() {
+ service, err := enterpriseconnection.NewEnterpriseConnectionService(organizationName)
+ checkError(err)
+
+ // First, fetch the existing connection to get current values
+ existingConnection, err := service.GetEnterpriseConnection(connectionName)
+ checkError(err)
+
+ // Validate the provided role
+ if err := service.ValidateRole(qoveryRole); err != nil {
+ utils.PrintlnError(fmt.Errorf("this role doesn't exist in your organization: %s - %v", qoveryRole, err))
+ return
+ }
+
+ // Resolve role name to ID
+ providedRoleNameOrCustomRoleId, err := service.ResolveProvidedRoleNameOrCustomRoleId(qoveryRole)
+ checkError(err)
+
+ // Parse IDP group names
+ idpGroupNamesArray := enterpriseconnection.ParseIdpGroupNames(idpGroupNames)
+
+ // Update group mappings
+ groupMappingsToUpdate := existingConnection.GroupMappings
+ groupMappingsToUpdate[providedRoleNameOrCustomRoleId] = idpGroupNamesArray
+
+ dto := enterpriseconnection.CreateConnectionUpdateDto(existingConnection.DefaultRole, existingConnection.EnforceGroupSync, groupMappingsToUpdate)
+ enterpriseConnection, err := service.UpdateEnterpriseConnection(connectionName, dto)
+ checkError(err)
+
+ err = service.DisplayGroupMappingsTable(enterpriseConnection.GroupMappings)
+ checkError(err)
+}
diff --git a/cmd/enterprise_connection_group_mappings_delete.go b/cmd/enterprise_connection_group_mappings_delete.go
new file mode 100644
index 00000000..393cb21e
--- /dev/null
+++ b/cmd/enterprise_connection_group_mappings_delete.go
@@ -0,0 +1,60 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/qovery/qovery-cli/pkg/enterpriseconnection"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var (
+ enterpriseConnectionGroupMappingsDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete enterprise connection group mapping",
+ Run: func(cmd *cobra.Command, args []string) {
+ deleteEnterpriseConnectionGroupMapping()
+ },
+ }
+)
+
+func init() {
+ enterpriseConnectionGroupMappingsDeleteCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ enterpriseConnectionGroupMappingsDeleteCmd.Flags().StringVarP(&connectionName, "connection", "c", "", "Connection Name")
+ enterpriseConnectionGroupMappingsDeleteCmd.Flags().StringVarP(&qoveryRole, "qovery-role", "q", "", "Qovery role to target")
+
+ _ = enterpriseConnectionGroupMappingsDeleteCmd.MarkFlagRequired("connection")
+
+ enterpriseConnectionGroupMappingsCmd.AddCommand(enterpriseConnectionGroupMappingsDeleteCmd)
+}
+
+func deleteEnterpriseConnectionGroupMapping() {
+ service, err := enterpriseconnection.NewEnterpriseConnectionService(organizationName)
+ checkError(err)
+
+ // First, fetch the existing connection to get current values
+ existingConnection, err := service.GetEnterpriseConnection(connectionName)
+ checkError(err)
+
+ // Resolve role name to ID
+ providedRoleNameOrCustomRoleId, err := service.ResolveProvidedRoleNameOrCustomRoleId(qoveryRole)
+ checkError(err)
+
+ groupMappingsToUpdate := existingConnection.GroupMappings
+
+ // Check if the qoveryRole exists in group mappings
+ if _, exists := groupMappingsToUpdate[providedRoleNameOrCustomRoleId]; !exists {
+ utils.PrintlnInfo(fmt.Sprintf("The role '%s' is not present in group mappings, skipping.", qoveryRole))
+ return
+ }
+
+ // Remove the qoveryRole from group mappings
+ delete(groupMappingsToUpdate, providedRoleNameOrCustomRoleId)
+
+ dto := enterpriseconnection.CreateConnectionUpdateDto(existingConnection.DefaultRole, existingConnection.EnforceGroupSync, groupMappingsToUpdate)
+ enterpriseConnection, err := service.UpdateEnterpriseConnection(connectionName, dto)
+ checkError(err)
+
+ err = service.DisplayGroupMappingsTable(enterpriseConnection.GroupMappings)
+ checkError(err)
+}
diff --git a/cmd/enterprise_connection_group_mappings_get.go b/cmd/enterprise_connection_group_mappings_get.go
new file mode 100644
index 00000000..373c7854
--- /dev/null
+++ b/cmd/enterprise_connection_group_mappings_get.go
@@ -0,0 +1,36 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/pkg/enterpriseconnection"
+ "github.com/spf13/cobra"
+)
+
+var (
+ enterpriseConnectionGroupMappingsGetCmd = &cobra.Command{
+ Use: "get",
+ Short: "Get enterprise connection group mappings",
+ Run: func(cmd *cobra.Command, args []string) {
+ getEnterpriseConnectionGroupMappings()
+ },
+ }
+)
+
+func init() {
+ enterpriseConnectionGroupMappingsGetCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ enterpriseConnectionGroupMappingsGetCmd.Flags().StringVarP(&connectionName, "connection", "c", "", "Connection Name")
+
+ _ = enterpriseConnectionGroupMappingsGetCmd.MarkFlagRequired("connection")
+
+ enterpriseConnectionGroupMappingsCmd.AddCommand(enterpriseConnectionGroupMappingsGetCmd)
+}
+
+func getEnterpriseConnectionGroupMappings() {
+ service, err := enterpriseconnection.NewEnterpriseConnectionService(organizationName)
+ checkError(err)
+
+ enterpriseConnection, err := service.GetEnterpriseConnection(connectionName)
+ checkError(err)
+
+ err = service.DisplayGroupMappingsTable(enterpriseConnection.GroupMappings)
+ checkError(err)
+}
diff --git a/cmd/enterprise_connection_update.go b/cmd/enterprise_connection_update.go
new file mode 100644
index 00000000..7307ac71
--- /dev/null
+++ b/cmd/enterprise_connection_update.go
@@ -0,0 +1,53 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/pkg/enterpriseconnection"
+ "github.com/spf13/cobra"
+)
+
+var (
+ enterpriseConnectionUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update enterprise connection information",
+ Run: func(cmd *cobra.Command, args []string) {
+ updateEnterpriseConnection()
+ },
+ }
+)
+
+func init() {
+ enterpriseConnectionUpdateCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ enterpriseConnectionUpdateCmd.Flags().StringVarP(&connectionName, "connection", "c", "", "Connection Name")
+ enterpriseConnectionUpdateCmd.Flags().StringVarP(&defaultRole, "default-role", "r", "", "Default Role")
+ enterpriseConnectionUpdateCmd.Flags().BoolVarP(&enforceGroupSync, "enforce-group-sync", "e", false, "")
+
+ _ = enterpriseConnectionUpdateCmd.MarkFlagRequired("connection")
+
+ enterpriseConnectionCmd.AddCommand(enterpriseConnectionUpdateCmd)
+}
+
+func updateEnterpriseConnection() {
+ service, err := enterpriseconnection.NewEnterpriseConnectionService(organizationName)
+ checkError(err)
+
+ // First, fetch the existing connection to get current values
+ existingConnection, err := service.GetEnterpriseConnection(connectionName)
+ checkError(err)
+
+ // Use existing default role if not provided
+ providedRoleNameOrCustomRoleId := defaultRole
+ if providedRoleNameOrCustomRoleId == "" {
+ providedRoleNameOrCustomRoleId = existingConnection.DefaultRole
+ } else {
+ // Resolve role name to ID if needed
+ providedRoleNameOrCustomRoleId, err = service.ResolveProvidedRoleNameOrCustomRoleId(providedRoleNameOrCustomRoleId)
+ checkError(err)
+ }
+
+ dto := enterpriseconnection.CreateConnectionUpdateDto(providedRoleNameOrCustomRoleId, enforceGroupSync, existingConnection.GroupMappings)
+ enterpriseConnection, err := service.UpdateEnterpriseConnection(connectionName, dto)
+ checkError(err)
+
+ err = service.DisplayEnterpriseConnection(enterpriseConnection)
+ checkError(err)
+}
diff --git a/cmd/env.go b/cmd/env.go
index f4f61d25..01b21f6d 100644
--- a/cmd/env.go
+++ b/cmd/env.go
@@ -6,7 +6,7 @@ import (
var envCmd = &cobra.Command{
Use: "env",
- Short: "Manage Qovery CLI Environment Variables and Secrets",
+ Short: "Manage Environment Variables and Secrets",
}
func init() {
diff --git a/cmd/env_import.go b/cmd/env_import.go
index c1c152ba..8c5694fc 100644
--- a/cmd/env_import.go
+++ b/cmd/env_import.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"strings"
"github.com/AlecAivazis/survey/v2"
@@ -46,12 +47,17 @@ var envImportCmd = &cobra.Command{
return
}
- application, _, err := utils.CurrentApplication()
+ service, err := utils.CurrentService(true)
if err != nil {
utils.PrintlnError(err)
os.Exit(0)
}
+ if service.Type != utils.ApplicationType && service.Type != utils.ContainerType {
+ utils.PrintlnError(fmt.Errorf("cannot import variables for service of type %s (only Application and Container are supported)", service.Type))
+ os.Exit(0)
+ }
+
utils.PrintlnInfo(fmt.Sprintf("dot env file to import: '%s'", dotEnvFilePath))
prompt := &survey.Select{
@@ -66,12 +72,9 @@ var envImportCmd = &cobra.Command{
return
}
- isSecrets := false
- if envVarOrSecret == "Secrets" {
- isSecrets = true
- }
+ isSecrets := envVarOrSecret == "Secrets"
- envsToImport := getEnvsToImport(envs)
+ envsToImport := getEnvsToImport(envs, utils.SortKeys)
if len(envsToImport) == 0 {
utils.PrintlnError(fmt.Errorf("no environment variables to import"))
return
@@ -89,27 +92,39 @@ var envImportCmd = &cobra.Command{
return
}
- overrideEnvVarOrSecret := false
- if overrideEnvVarOrSecretString == "Yes" {
- overrideEnvVarOrSecret = true
- }
+ overrideEnvVarOrSecret := overrideEnvVarOrSecretString == "Yes"
var errors []string
for k, v := range envsToImport {
var err error
- if isSecrets {
- if overrideEnvVarOrSecret {
- _ = utils.DeleteSecret(application, k)
- }
- err = utils.AddSecret(application, k, v)
+ // Use different API calls based on service type
+ if service.Type == utils.ContainerType {
+ if isSecrets {
+ if overrideEnvVarOrSecret {
+ _ = utils.DeleteContainerSecret(service.ID, k)
+ }
+ err = utils.AddContainerSecret(service.ID, k, v)
+ } else {
+ if overrideEnvVarOrSecret {
+ _ = utils.DeleteContainerEnvironmentVariable(service.ID, k)
+ }
+ err = utils.AddContainerEnvironmentVariable(service.ID, k, v)
+ }
} else {
- if overrideEnvVarOrSecret {
- _ = utils.DeleteEnvironmentVariable(application, k)
+ // ApplicationType
+ if isSecrets {
+ if overrideEnvVarOrSecret {
+ _ = utils.DeleteSecret(service.ID, k)
+ }
+ err = utils.AddSecret(service.ID, k, v)
+ } else {
+ if overrideEnvVarOrSecret {
+ _ = utils.DeleteEnvironmentVariable(service.ID, k)
+ }
+ err = utils.AddEnvironmentVariable(service.ID, k, v)
}
-
- err = utils.AddEnvironmentVariable(application, k, v)
}
if err != nil {
@@ -162,11 +177,25 @@ func scanAndSelectDotEnvFile() (string, error) {
return result, nil
}
-func getEnvsToImport(envs map[string]string) map[string]string {
+func getEnvsToImport(envs map[string]string, sortKeys bool) map[string]string {
var envKeys []string
- for k, v := range envs {
- envKeys = append(envKeys, fmt.Sprintf("%s=%s", k, v))
+ if sortKeys {
+ // Get sorted keys first
+ var keys []string
+ for k := range envs {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ // Build envKeys in sorted order
+ for _, k := range keys {
+ envKeys = append(envKeys, fmt.Sprintf("%s=%s", k, envs[k]))
+ }
+ } else {
+ for k, v := range envs {
+ envKeys = append(envKeys, fmt.Sprintf("%s=%s", k, v))
+ }
}
prompt := &survey.MultiSelect{
@@ -191,4 +220,5 @@ func getEnvsToImport(envs map[string]string) map[string]string {
func init() {
envCmd.AddCommand(envImportCmd)
+ envImportCmd.Flags().BoolVarP(&utils.SortKeys, "sort", "", false, "Sort environment variables by key")
}
diff --git a/cmd/env_parse.go b/cmd/env_parse.go
index c876ec05..aec33d68 100644
--- a/cmd/env_parse.go
+++ b/cmd/env_parse.go
@@ -62,7 +62,7 @@ var envParseCmd = &cobra.Command{
}
for key, value := range envs {
- fmt.Println(fmt.Sprintf("%s=%s", key, value))
+ fmt.Printf("%s=%s\n", key, value)
}
},
}
diff --git a/cmd/environment.go b/cmd/environment.go
new file mode 100644
index 00000000..3681ee6d
--- /dev/null
+++ b/cmd/environment.go
@@ -0,0 +1,31 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var targetEnvironmentName string
+var newEnvironmentName string
+var clusterName string
+var environmentType string
+var applyDeploymentRule bool
+var targetProjectName string
+
+var environmentCmd = &cobra.Command{
+ Use: "environment",
+ Short: "Manage environments",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(environmentCmd)
+}
diff --git a/cmd/environment_cancel.go b/cmd/environment_cancel.go
new file mode 100644
index 00000000..52feadeb
--- /dev/null
+++ b/cmd/environment_cancel.go
@@ -0,0 +1,56 @@
+package cmd
+
+import (
+ "context"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var forceCancel bool
+
+var environmentCancelCmd = &cobra.Command{
+ Use: "cancel",
+ Short: "Cancel an environment deployment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ _, _, err = client.EnvironmentActionsAPI.CancelEnvironmentDeployment(context.Background(), envId).CancelEnvironmentDeploymentRequest(qovery.CancelEnvironmentDeploymentRequest{ForceCancel: &forceCancel}).Execute()
+ if err != nil {
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println("Environment is canceling!")
+
+ if watchFlag {
+ utils.WatchEnvironment(envId, qovery.STATEENUM_CANCELED, client)
+ }
+ },
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentCancelCmd)
+ environmentCancelCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentCancelCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentCancelCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentCancelCmd.Flags().BoolVarP(&forceCancel, "force", "f", false, "Force cancel")
+ environmentCancelCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch environment status until it's ready or an error occurs")
+}
diff --git a/cmd/environment_clone.go b/cmd/environment_clone.go
new file mode 100644
index 00000000..e99ccb5c
--- /dev/null
+++ b/cmd/environment_clone.go
@@ -0,0 +1,114 @@
+package cmd
+
+import (
+ "context"
+ "github.com/go-errors/errors"
+ "io"
+ "os"
+ "strings"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var environmentCloneCmd = &cobra.Command{
+ Use: "clone",
+ Short: "Clone an environment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ orgId, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ req := qovery.CloneEnvironmentRequest{
+ Name: newEnvironmentName,
+ ApplyDeploymentRule: &applyDeploymentRule,
+ }
+
+ if clusterName != "" {
+ clusters, _, err := client.ClustersAPI.ListOrganizationCluster(context.Background(), orgId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if err == nil {
+ for _, c := range clusters.GetResults() {
+ if strings.EqualFold(c.Name, clusterName) {
+ req.ClusterId = &c.Id
+ break
+ }
+ }
+ }
+ }
+
+ if environmentType != "" {
+ switch strings.ToUpper(environmentType) {
+ case "DEVELOPMENT":
+ req.Mode = qovery.EnvironmentModeEnum.Ptr(qovery.ENVIRONMENTMODEENUM_DEVELOPMENT)
+ case "PRODUCTION":
+ req.Mode = qovery.EnvironmentModeEnum.Ptr(qovery.ENVIRONMENTMODEENUM_PRODUCTION)
+ case "STAGING":
+ req.Mode = qovery.EnvironmentModeEnum.Ptr(qovery.ENVIRONMENTMODEENUM_STAGING)
+ }
+ }
+
+ if targetProjectName != "" {
+ targetProjectId, err := getProjectContextResourceId(client, targetProjectName, orgId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ req.ProjectId = &targetProjectId
+ }
+
+ _, res, err := client.EnvironmentActionsAPI.CloneEnvironment(context.Background(), envId).CloneEnvironmentRequest(req).Execute()
+
+ if err != nil {
+ // print http body error message
+ if res != nil && !strings.Contains(res.Status, "200") {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ }
+
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println("Environment is cloned!")
+ },
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentCloneCmd)
+ environmentCloneCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ environmentCloneCmd.Flags().StringVarP(&projectName, "project", "p", "", "Project Name")
+ environmentCloneCmd.Flags().StringVarP(&environmentName, "environment", "e", "", "Environment Name to clone")
+ environmentCloneCmd.Flags().StringVarP(&newEnvironmentName, "new-environment-name", "n", "", "New Environment Name")
+ environmentCloneCmd.Flags().StringVarP(&clusterName, "cluster", "c", "", "Cluster Name where to clone the environment")
+ environmentCloneCmd.Flags().StringVarP(&environmentType, "environment-type", "t", "", "Environment type for the new environment (DEVELOPMENT|STAGING|PRODUCTION)")
+ environmentCloneCmd.Flags().BoolVarP(&applyDeploymentRule, "apply-deployment-rule", "", false, "Enable applying deployment rules on the new environment instead of having a pristine clone. Default: false")
+ environmentCloneCmd.Flags().StringVarP(&targetProjectName, "target-project", "", "", "Target Project Name")
+
+ _ = environmentCloneCmd.MarkFlagRequired("new-environment-name")
+}
diff --git a/cmd/environment_delete.go b/cmd/environment_delete.go
new file mode 100644
index 00000000..0a54828d
--- /dev/null
+++ b/cmd/environment_delete.go
@@ -0,0 +1,38 @@
+package cmd
+
+import (
+ "context"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "time"
+)
+
+var environmentDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete an environment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ _, err := client.EnvironmentMainCallsAPI.
+ DeleteEnvironment(context.Background(), envId).
+ Execute()
+ checkError(err)
+ utils.Println("Request to delete environment has been queued...")
+ if watchFlag {
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent
+ utils.WatchEnvironment(envId, qovery.STATEENUM_DELETED, client)
+ }
+ },
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentDeleteCmd)
+ environmentDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentDeleteCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch environment status until it's ready or an error occurs")
+}
diff --git a/cmd/environment_deploy.go b/cmd/environment_deploy.go
new file mode 100644
index 00000000..aa594b3c
--- /dev/null
+++ b/cmd/environment_deploy.go
@@ -0,0 +1,398 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var skipPausedServicesFlag bool
+
+var environmentDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy an environment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ if (servicesJson != "" || applicationNames != "" || containerNames != "" || lifecycleNames != "" ||
+ cronjobNames != "" || helmNames != "") && skipPausedServicesFlag {
+ utils.PrintlnError(fmt.Errorf("you can't use --skip-paused-services flag with --services, " +
+ "--applications, --containers, --lifecycles, --cronjobs or --helms flags"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if servicesJson != "" {
+ // convert servicesJson to DeployAllRequest
+ var deployAllRequest qovery.DeployAllRequest
+ err := json.Unmarshal([]byte(servicesJson), &deployAllRequest)
+ checkError(err)
+
+ _, _, err = client.EnvironmentActionsAPI.DeployAllServices(context.Background(), envId).DeployAllRequest(deployAllRequest).Execute()
+ checkError(err)
+
+ utils.Println("Request to deploy services has been queued..")
+ } else if applicationNames != "" || containerNames != "" || lifecycleNames != "" || cronjobNames != "" || helmNames != "" {
+ deploymentRequest := getDeploymentRequestForMultipleServices(client, envId, applicationNames, containerNames, lifecycleNames, cronjobNames, helmNames)
+ _, _, err := client.EnvironmentActionsAPI.DeployAllServices(context.Background(), envId).DeployAllRequest(deploymentRequest).Execute()
+ checkError(err)
+
+ utils.Println("Request to deploy services has been queued..")
+ }
+
+ if skipPausedServicesFlag {
+ // Paused services shouldn't be deployed, let's gather services status
+ servicesIDsToDeploy, err := getEligibleServices(client, envId, []qovery.StateEnum{qovery.STATEENUM_STOPPED})
+ checkError(err)
+
+ // Deploy the non stopped services from the env
+ request := qovery.DeployAllRequest{}
+ // Adding services to be deployed
+ for _, applicationID := range servicesIDsToDeploy.ApplicationsIDs {
+ request.Applications = append(request.Applications, qovery.DeployAllRequestApplicationsInner{ApplicationId: applicationID})
+ utils.Println(fmt.Sprintf("Request to deploy application %s has been queued..", applicationID))
+ }
+ for _, containerID := range servicesIDsToDeploy.ContainersIDs {
+ request.Containers = append(request.Containers, qovery.DeployAllRequestContainersInner{Id: containerID})
+ utils.Println(fmt.Sprintf("Request to deploy container %s has been queued..", containerID))
+
+ }
+ for _, helmID := range servicesIDsToDeploy.HelmsIDs {
+ request.Helms = append(request.Helms, qovery.DeployAllRequestHelmsInner{Id: &helmID})
+ utils.Println(fmt.Sprintf("Request to deploy helm %s has been queued..", helmID))
+ }
+ for _, jobID := range servicesIDsToDeploy.JobsIDs {
+ request.Jobs = append(request.Jobs, qovery.DeployAllRequestJobsInner{Id: &jobID})
+ utils.Println(fmt.Sprintf("Request to deploy job %s has been queued..", jobID))
+ }
+ for _, databaseID := range servicesIDsToDeploy.DatabasesIDs {
+ request.Databases = append(request.Databases, databaseID)
+ utils.Println(fmt.Sprintf("Request to deploy database %s has been queued..", databaseID))
+ }
+
+ _, _, err = client.EnvironmentActionsAPI.DeployAllServices(context.Background(), envId).DeployAllRequest(request).Execute()
+ checkError(err)
+
+ } else if servicesJson == "" && applicationNames == "" && containerNames == "" && lifecycleNames == "" &&
+ cronjobNames == "" && helmNames == "" {
+ // Deploy the whole env
+ _, _, err := client.EnvironmentActionsAPI.DeployEnvironment(context.Background(), envId).Execute()
+ checkError(err)
+ utils.Println("Request to deploy environment has been queued..")
+ }
+
+ if watchFlag {
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent
+ utils.WatchEnvironment(envId, qovery.STATEENUM_DEPLOYED, client)
+ }
+ },
+}
+
+/**
+ * Get deployment request for multiple services
+ */
+func getDeploymentRequestForMultipleServices(
+ client *qovery.APIClient,
+ envId string,
+ applicationNames string,
+ containerNames string,
+ lifecycleNames string,
+ cronjobNames string,
+ helmNames string,
+) qovery.DeployAllRequest {
+ // Deploy the services from the env
+ request := qovery.DeployAllRequest{}
+
+ if applicationNames != "" {
+ // Adding applications to be deployed
+ for _, nameAndVersion := range strings.Split(applicationNames, ",") {
+ name, version := splitServiceNameAndVersion(nameAndVersion)
+
+ apps, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ app := utils.FindByApplicationName(apps.GetResults(), name)
+
+ if app == nil {
+ utils.PrintlnError(fmt.Errorf("application %s not found", name))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ request.Applications = append(request.Applications, qovery.DeployAllRequestApplicationsInner{ApplicationId: app.Id, GitCommitId: version})
+ }
+ }
+
+ if containerNames != "" {
+ // Adding containers to be deployed
+ for _, nameAndVersion := range strings.Split(containerNames, ",") {
+ name, version := splitServiceNameAndVersion(nameAndVersion)
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), name)
+
+ request.Containers = append(request.Containers, qovery.DeployAllRequestContainersInner{Id: container.Id, ImageTag: version})
+ }
+ }
+
+ if lifecycleNames != "" || cronjobNames != "" {
+ jobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if lifecycleNames != "" {
+ // Adding lifecycle to be deployed
+ for _, nameAndVersion := range strings.Split(lifecycleNames, ",") {
+ name, version := splitServiceNameAndVersion(nameAndVersion)
+ job, gitCommitId, imageTag := getLifecycleJobGitCommitAndImageTag(jobs.GetResults(), name)
+
+ if job == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", name))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ req := qovery.DeployAllRequestJobsInner{Id: &job.LifecycleJobResponse.Id}
+ if gitCommitId != nil {
+ req.GitCommitId = version
+ } else if imageTag != nil {
+ req.ImageTag = version
+ }
+
+ request.Jobs = append(request.Jobs, req)
+ }
+ }
+
+ if cronjobNames != "" {
+ // Adding cronjobs to be deployed
+ for _, nameAndVersion := range strings.Split(cronjobNames, ",") {
+ name, version := splitServiceNameAndVersion(nameAndVersion)
+
+ job, gitCommitId, imageTag := getCronjobGitCommitAndImageTag(jobs.GetResults(), name)
+
+ if job == nil {
+ utils.PrintlnError(fmt.Errorf("cronjob %s not found", name))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ req := qovery.DeployAllRequestJobsInner{Id: &job.CronJobResponse.Id}
+ if gitCommitId != nil {
+ req.GitCommitId = version
+ } else if imageTag != nil {
+ req.ImageTag = version
+ }
+
+ request.Jobs = append(request.Jobs, req)
+ }
+ }
+ }
+
+ if helmNames != "" {
+ // Adding helms to be deployed
+ for _, nameAndVersion := range strings.Split(helmNames, ",") {
+ name, version := splitServiceNameAndVersion(nameAndVersion)
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), name)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", name))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ gitCommitId, chartVersion := getHelmCommitAndChartVersion(client, name)
+
+ req := qovery.DeployAllRequestHelmsInner{Id: &helm.Id}
+ if gitCommitId != nil {
+ req.GitCommitId = version
+ } else if chartVersion != nil {
+ req.ChartVersion = version
+ }
+
+ request.Helms = append(request.Helms, req)
+ }
+ }
+
+ return request
+}
+
+type Services struct {
+ ApplicationsIDs []string
+ ContainersIDs []string
+ HelmsIDs []string
+ JobsIDs []string
+ DatabasesIDs []string
+}
+
+func getEligibleServices(client *qovery.APIClient, envId string, servicesStatusesToExclude []qovery.StateEnum) (Services, error) {
+ nonStoppedServices := Services{
+ ApplicationsIDs: make([]string, 0),
+ ContainersIDs: make([]string, 0),
+ HelmsIDs: make([]string, 0),
+ JobsIDs: make([]string, 0),
+ DatabasesIDs: make([]string, 0),
+ }
+ envStatuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+ if err != nil {
+ return nonStoppedServices, err
+ }
+
+ // Gather all non-stopped services
+ for _, serviceStatus := range envStatuses.Applications {
+ if !slices.Contains(servicesStatusesToExclude, serviceStatus.GetState()) {
+ nonStoppedServices.ApplicationsIDs = append(nonStoppedServices.ApplicationsIDs, serviceStatus.Id)
+ }
+ }
+ for _, serviceStatus := range envStatuses.Containers {
+ if !slices.Contains(servicesStatusesToExclude, serviceStatus.GetState()) {
+ nonStoppedServices.ContainersIDs = append(nonStoppedServices.ContainersIDs, serviceStatus.Id)
+ }
+ }
+ for _, serviceStatus := range envStatuses.Helms {
+ if !slices.Contains(servicesStatusesToExclude, serviceStatus.GetState()) {
+ nonStoppedServices.HelmsIDs = append(nonStoppedServices.HelmsIDs, serviceStatus.Id)
+ }
+ }
+ for _, serviceStatus := range envStatuses.Jobs {
+ if !slices.Contains(servicesStatusesToExclude, serviceStatus.GetState()) {
+ nonStoppedServices.JobsIDs = append(nonStoppedServices.JobsIDs, serviceStatus.Id)
+ }
+ }
+ for _, serviceStatus := range envStatuses.Databases {
+ if !slices.Contains(servicesStatusesToExclude, serviceStatus.GetState()) {
+ nonStoppedServices.DatabasesIDs = append(nonStoppedServices.DatabasesIDs, serviceStatus.Id)
+ }
+ }
+
+ return nonStoppedServices, nil
+}
+
+/**
+ * Split service name and version (if provided)
+ */
+func splitServiceNameAndVersion(service string) (string, *string) {
+ split := strings.Split(service, ":")
+ if len(split) == 1 {
+ return split[0], nil
+ }
+
+ return split[0], &split[1]
+}
+
+func getLifecycleJobGitCommitAndImageTag(jobs []qovery.JobResponse, jobName string) (*qovery.JobResponse, *string, *string) {
+ var commitId, imageTag *string
+
+ job := utils.FindByJobName(jobs, jobName)
+
+ if job == nil {
+ return nil, nil, nil
+ }
+
+ if job.LifecycleJobResponse.Source.BaseJobResponseAllOfSourceOneOf != nil {
+ // image tag
+ image := job.LifecycleJobResponse.Source.BaseJobResponseAllOfSourceOneOf.GetImage()
+ tag := image.GetTag()
+ imageTag = &tag
+ } else if job.LifecycleJobResponse.Source.BaseJobResponseAllOfSourceOneOf1 != nil {
+ // commit id
+ docker := job.LifecycleJobResponse.Source.BaseJobResponseAllOfSourceOneOf1.GetDocker()
+ commitId = docker.GitRepository.DeployedCommitId
+ }
+
+ return job, commitId, imageTag
+}
+
+func getCronjobGitCommitAndImageTag(jobs []qovery.JobResponse, jobName string) (*qovery.JobResponse, *string, *string) {
+ var commitId, imageTag *string
+
+ job := utils.FindByJobName(jobs, jobName)
+
+ if job == nil {
+ return nil, nil, nil
+ }
+
+ if job.CronJobResponse.Source.BaseJobResponseAllOfSourceOneOf != nil {
+ // image tag
+ image := job.CronJobResponse.Source.BaseJobResponseAllOfSourceOneOf.GetImage()
+ tag := image.GetTag()
+ imageTag = &tag
+ } else if job.CronJobResponse.Source.BaseJobResponseAllOfSourceOneOf1 != nil {
+ // commit id
+ docker := job.CronJobResponse.Source.BaseJobResponseAllOfSourceOneOf1.GetDocker()
+ commitId = docker.GitRepository.DeployedCommitId
+ }
+
+ return job, commitId, imageTag
+}
+
+func getHelmCommitAndChartVersion(client *qovery.APIClient, helmId string) (*string, *string) {
+ var commitId, chartVersion *string
+
+ // check if the helm version is a chart version or a commit id
+ helm, _, err := client.HelmMainCallsAPI.GetHelm(context.Background(), helmId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+
+ if helm.Source.HelmResponseAllOfSourceOneOf != nil {
+ // chart version
+ git := helm.Source.HelmResponseAllOfSourceOneOf.GetGit()
+ commitId = git.GitRepository.DeployedCommitId
+ } else if helm.Source.HelmResponseAllOfSourceOneOf1 != nil {
+ // commit id
+ git := helm.Source.HelmResponseAllOfSourceOneOf1.GetRepository()
+ chartVersion = &git.ChartVersion
+ }
+
+ return commitId, chartVersion
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentDeployCmd)
+ environmentDeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentDeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentDeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentDeployCmd.Flags().StringVarP(&servicesJson, "services", "", "", "Services to deploy (JSON Format: https://api-doc.qovery.com/#tag/Environment-Actions/operation/deployAllServices)")
+ environmentDeployCmd.Flags().StringVarP(&applicationNames, "applications", "", "", "Applications to deploy E.g. --applications app1:commit_id,app2:commit_id). If you omit the commit id, the same commit will be used")
+ environmentDeployCmd.Flags().StringVarP(&containerNames, "containers", "", "", "Containers to deploy E.g. --containers container1:image_tag,container2:image_tag). If you omit the image tag, the same image tag will be used")
+ environmentDeployCmd.Flags().StringVarP(&lifecycleNames, "lifecycles", "", "", "Lifecycle to deploy E.g. --lifecycles job1:image_tag|git_commit_id,job2:image_tag|git_commit_id). If you omit the git commit id or image tag, the same version will be used")
+ environmentDeployCmd.Flags().StringVarP(&cronjobNames, "cronjobs", "", "", "Cronjobs to deploy E.g. --cronjobs cronjob1:git_commit_id,cronjob2:git_commit_id). If you omit the git commit id, the same version will be used")
+ environmentDeployCmd.Flags().StringVarP(&helmNames, "helms", "", "", "Helms to deploy E.g. --helms helm1:chart_version|git_commit_id,helm2:chart_version|git_commit_id). If you omit the chart version or git commit id, the same version will be used")
+ environmentDeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch environment status until it's ready or an error occurs")
+ environmentDeployCmd.Flags().BoolVarP(&skipPausedServicesFlag, "skip-paused-services", "", false, "Skip paused services: paused services won't be started / deployed")
+}
diff --git a/cmd/environment_deployment.go b/cmd/environment_deployment.go
new file mode 100644
index 00000000..61fa6a0e
--- /dev/null
+++ b/cmd/environment_deployment.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var environmentDeploymentCmd = &cobra.Command{
+ Use: "deployment",
+ Short: "Manage environment deployments",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentDeploymentCmd)
+}
diff --git a/cmd/environment_deployment_explain.go b/cmd/environment_deployment_explain.go
new file mode 100644
index 00000000..e81e15cc
--- /dev/null
+++ b/cmd/environment_deployment_explain.go
@@ -0,0 +1,292 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "github.com/xlab/treeprint"
+ "os"
+ "time"
+)
+
+var level string
+
+const (
+ StageLevel = 1
+ ServiceLevel = 2
+ StepLevel = 3
+ MessageLevel = 4
+ AllLevel int = 5
+)
+
+var environmentDeploymentExplainCmd = &cobra.Command{
+ Use: "explain",
+ Short: "Explain environment deployment -- give details about what happened during the deployment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if level != "" && level != "all" && level != "stage" && level != "service" && level != "step" && level != "message" {
+ utils.PrintlnError(fmt.Errorf("invalid value for --show-only: %s", level))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, environmentId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ environment, _, err := client.EnvironmentMainCallsAPI.GetEnvironment(context.Background(), environmentId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ logsQuery := client.EnvironmentLogsAPI.ListEnvironmentLogs(context.Background(), environmentId)
+ if id != "" {
+ logsQuery = logsQuery.Version(id)
+ }
+
+ logs, _, err := logsQuery.Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ mLevel := AllLevel
+ switch level {
+ case "", "all":
+ mLevel = AllLevel
+ case "stage":
+ mLevel = StageLevel
+ case "service":
+ mLevel = ServiceLevel
+ case "step":
+ mLevel = StepLevel
+ case "message":
+ mLevel = MessageLevel
+ }
+
+ tree := treeprint.New()
+ envBranch := tree.AddBranch(fmt.Sprintf("Environment: %s [duration: %s]", environment.Name, getDurationFromLogs(logs)))
+
+ branchByStage := make(map[string]treeprint.Tree)
+
+ for stageIdx, stage := range getStagesFromLogs(logs) {
+ stageStartTime, stageEndTime := getStartTimeAndEndTimeByStage(stage, logs)
+ branch := envBranch.AddBranch(fmt.Sprintf("Stage %d: %s [duration: %s]", stageIdx+1, stage, utils.GetDuration(stageStartTime, stageEndTime)))
+ branchByStage[stage] = branch
+
+ if mLevel >= ServiceLevel {
+ for _, service := range getServicesFromLogsByStage(stage, logs) {
+ serviceStartTime, serviceEndTime := getStartTimeAndEndTimeByServiceAndStage(service, stage, logs)
+ serviceBranch := branch.AddBranch(fmt.Sprintf("%s [duration: %s]", service, utils.GetDuration(serviceStartTime, serviceEndTime)))
+
+ if mLevel >= StepLevel {
+ stepIdx := 0
+ for _, step := range getStepsFromLogsByService(service, logs) {
+ stepStartTime, stepEndTime := getStepStartTimeAndEndTimeFromLogsByServiceAndStep(service, step, logs)
+ if stepEndTime.Sub(stepStartTime).Seconds() > 0 {
+ stepIdx++
+ // only display if step took more than 0 seconds
+ stepBranch := serviceBranch.AddBranch(fmt.Sprintf("Step %d: %s [duration: %s]", stepIdx, step, utils.GetDuration(stepStartTime, stepEndTime)))
+
+ if mLevel >= MessageLevel {
+ for _, stepLog := range filterLogsByServiceAndStep(service, step, logs) {
+ message := stepLog.GetMessage()
+ stepBranch.AddNode(message.GetSafeMessage())
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fmt.Println(tree.String())
+
+ //var data [][]string
+ //
+ //for _, log := range logs {
+ // message := log.GetMessage()
+ // data = append(data, []string{
+ // log.Timestamp.String(),
+ // log.Details.StageLevel.GetName(),
+ // log.Details.StageLevel.GetStep(),
+ // log.Details.Transmitter.GetName(),
+ // log.Details.Transmitter.GetType(),
+ // message.GetSafeMessage(),
+ // })
+ //}
+ //
+ //err = utils.PrintTable([]string{"Timestamp", "StageLevel", "StepLevel", "ServiceLevel", "ServiceLevel Type", "Message"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getDurationFromLogs(logs []qovery.EnvironmentLogs) string {
+ var startTime time.Time
+ var endTime time.Time
+
+ for _, log := range logs {
+ if startTime.IsZero() || startTime.After(log.Timestamp) {
+ startTime = log.Timestamp
+ }
+
+ if endTime.IsZero() || endTime.Before(log.Timestamp) {
+ endTime = log.Timestamp
+ }
+ }
+
+ return utils.GetDuration(startTime, endTime)
+}
+
+func getStagesFromLogs(logs []qovery.EnvironmentLogs) []string {
+ stages := make(map[string]bool)
+ var stagesList []string
+
+ for _, log := range logs {
+ stageName := log.Details.Stage.GetName()
+ if _, ok := stages[stageName]; !ok {
+ stages[stageName] = true
+ stagesList = append(stagesList, stageName)
+ }
+ }
+
+ return stagesList
+}
+
+func getStartTimeAndEndTimeByStage(stage string, logs []qovery.EnvironmentLogs) (time.Time, time.Time) {
+ var startTime time.Time
+ var endTime time.Time
+
+ for _, log := range logs {
+ if log.Details.Stage.GetName() == stage {
+ if startTime.IsZero() || startTime.After(log.Timestamp) {
+ startTime = log.Timestamp
+ }
+
+ if endTime.IsZero() || endTime.Before(log.Timestamp) {
+ endTime = log.Timestamp
+ }
+ }
+ }
+
+ return startTime, endTime
+}
+
+func getStartTimeAndEndTimeByServiceAndStage(service string, stage string, logs []qovery.EnvironmentLogs) (time.Time, time.Time) {
+ var startTime time.Time
+ var endTime time.Time
+
+ for _, log := range logs {
+ if log.Details.Stage.GetName() == stage && log.Details.Transmitter.GetName() == service {
+ if startTime.IsZero() || startTime.After(log.Timestamp) {
+ startTime = log.Timestamp
+ }
+
+ if endTime.IsZero() || endTime.Before(log.Timestamp) {
+ endTime = log.Timestamp
+ }
+ }
+ }
+
+ return startTime, endTime
+}
+
+func getServicesFromLogsByStage(stage string, logs []qovery.EnvironmentLogs) []string {
+ services := make(map[string]bool)
+ var servicesList []string
+
+ for _, log := range logs {
+ serviceName := log.Details.Transmitter.GetName()
+ if log.Details.Stage.GetName() == stage && log.Details.Transmitter.GetType() != "Environment" {
+ if _, ok := services[serviceName]; !ok {
+ services[serviceName] = true
+ servicesList = append(servicesList, serviceName)
+ }
+ }
+ }
+
+ return servicesList
+}
+
+func getStepsFromLogsByService(service string, logs []qovery.EnvironmentLogs) []string {
+ steps := make(map[string]bool)
+ var stepsList []string
+
+ for _, log := range logs {
+ stepName := log.Details.Stage.GetStep()
+ if log.Details.Transmitter.GetName() == service {
+ if _, ok := steps[stepName]; !ok {
+ steps[stepName] = true
+ stepsList = append(stepsList, stepName)
+ }
+ }
+ }
+
+ return stepsList
+}
+
+func getStepStartTimeAndEndTimeFromLogsByServiceAndStep(service string, step string, logs []qovery.EnvironmentLogs) (time.Time, time.Time) {
+ var startTime time.Time
+ var endTime time.Time
+
+ for _, log := range logs {
+ if log.Details.Transmitter.GetName() == service && log.Details.Stage.GetStep() == step {
+ if startTime.IsZero() || startTime.After(log.Timestamp) {
+ startTime = log.Timestamp
+ }
+
+ if endTime.IsZero() || endTime.Before(log.Timestamp) {
+ endTime = log.Timestamp
+ }
+ }
+ }
+
+ return startTime, endTime
+}
+
+func filterLogsByServiceAndStep(service string, step string, logs []qovery.EnvironmentLogs) []qovery.EnvironmentLogs {
+ var filteredLogs []qovery.EnvironmentLogs
+
+ for _, log := range logs {
+ if log.Details.Transmitter.GetName() == service && log.Details.Stage.GetStep() == step {
+ filteredLogs = append(filteredLogs, log)
+ }
+ }
+
+ return filteredLogs
+}
+
+func init() {
+ environmentDeploymentCmd.AddCommand(environmentDeploymentExplainCmd)
+ environmentDeploymentExplainCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentDeploymentExplainCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentDeploymentExplainCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentDeploymentExplainCmd.Flags().StringVarP(&id, "id", "", "", "Deployment Id")
+ environmentDeploymentExplainCmd.Flags().StringVarP(&level, "level", "", "all", "Show only: (default: all)")
+}
diff --git a/cmd/environment_deployment_list.go b/cmd/environment_deployment_list.go
new file mode 100644
index 00000000..2410f7df
--- /dev/null
+++ b/cmd/environment_deployment_list.go
@@ -0,0 +1,97 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var environmentDeploymentListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List environment deployments",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, environmentId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ deployments, _, err := client.EnvironmentDeploymentHistoryAPI.ListEnvironmentDeploymentHistory(context.Background(), environmentId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(toDeploymentListJsonOutput(deployments.GetResults()))
+ return
+ }
+
+ var data [][]string
+
+ for _, deployment := range deployments.GetResults() {
+ data = append(data, []string{
+ deployment.Id,
+ deployment.GetCreatedAt().String(),
+ utils.GetStatusTextWithColor(deployment.GetStatus()),
+ utils.GetDuration(deployment.GetCreatedAt(), deployment.GetUpdatedAt()),
+ })
+ }
+
+ err = utils.PrintTable([]string{"Id", "Deployed At", "Status", "Duration"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func toDeploymentListJsonOutput(deployments []qovery.DeploymentHistoryEnvironment) string {
+ var results []interface{}
+
+ for _, deployment := range deployments {
+ results = append(results, map[string]interface{}{
+ "id": deployment.Id,
+ "created_at": utils.ToIso8601(&deployment.CreatedAt),
+ "status": deployment.GetStatus(),
+ "deployment_duration_in_seconds": int(deployment.GetUpdatedAt().Sub(deployment.GetCreatedAt()).Seconds()),
+ })
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ environmentDeploymentCmd.AddCommand(environmentDeploymentListCmd)
+ environmentDeploymentListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentDeploymentListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentDeploymentListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentDeploymentListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/environment_env.go b/cmd/environment_env.go
new file mode 100644
index 00000000..2d783694
--- /dev/null
+++ b/cmd/environment_env.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentEnvCmd = &cobra.Command{
+ Use: "env",
+ Short: "Manage environment variables and secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentEnvCmd)
+}
diff --git a/cmd/environment_env_alias.go b/cmd/environment_env_alias.go
new file mode 100644
index 00000000..3e4f1b7c
--- /dev/null
+++ b/cmd/environment_env_alias.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentEnvAliasCmd = &cobra.Command{
+ Use: "alias",
+ Short: "Manage environment variable and secret aliases",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ environmentEnvCmd.AddCommand(environmentEnvAliasCmd)
+}
diff --git a/cmd/environment_env_alias_create.go b/cmd/environment_env_alias_create.go
new file mode 100644
index 00000000..285bba0c
--- /dev/null
+++ b/cmd/environment_env_alias_create.go
@@ -0,0 +1,65 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentEnvAliasCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create environment variable or secret alias",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ checkError(err)
+
+ environment := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+
+ if environment == nil {
+ utils.PrintlnError(fmt.Errorf("environment %s not found", environmentName))
+ utils.PrintlnInfo("You can list all environments with: qovery environment list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateEnvironmentAlias(client, projectId, environment.Id, utils.Key, utils.Alias, utils.EnvironmentScope)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("Alias %s has been created", pterm.FgBlue.Sprintf("%s", utils.Alias)))
+ },
+}
+
+func init() {
+ environmentEnvAliasCmd.AddCommand(environmentEnvAliasCreateCmd)
+ environmentEnvAliasCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentEnvAliasCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentEnvAliasCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentEnvAliasCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ environmentEnvAliasCreateCmd.Flags().StringVarP(&utils.Alias, "alias", "", "", "Environment variable or secret alias")
+ environmentEnvAliasCreateCmd.Flags().StringVarP(&utils.EnvironmentScope, "scope", "", "ENVIRONMENT", "Scope of this alias ")
+
+ _ = environmentEnvAliasCreateCmd.MarkFlagRequired("project")
+ _ = environmentEnvAliasCreateCmd.MarkFlagRequired("environment")
+ _ = environmentEnvAliasCreateCmd.MarkFlagRequired("key")
+ _ = environmentEnvAliasCreateCmd.MarkFlagRequired("alias")
+}
diff --git a/cmd/environment_env_create.go b/cmd/environment_env_create.go
new file mode 100644
index 00000000..7a5a43f2
--- /dev/null
+++ b/cmd/environment_env_create.go
@@ -0,0 +1,65 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentEnvCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ checkError(err)
+
+ environment := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+ if environment == nil {
+ utils.PrintlnError(fmt.Errorf("environment %s not found", environmentName))
+ utils.PrintlnInfo("You can list all environments with: qovery environment list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateEnvironmentVariable(client, projectId, environment.Id, utils.Key, utils.Value, utils.IsSecret)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ environmentEnvCmd.AddCommand(environmentEnvCreateCmd)
+ environmentEnvCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentEnvCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentEnvCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentEnvCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ environmentEnvCreateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+ environmentEnvCreateCmd.Flags().StringVarP(&utils.EnvironmentScope, "scope", "", "ENVIRONMENT", "Scope of this env var ")
+ environmentEnvCreateCmd.Flags().BoolVarP(&utils.IsSecret, "secret", "", false, "This environment variable is a secret")
+
+ _ = environmentEnvCreateCmd.MarkFlagRequired("project")
+ _ = environmentEnvCreateCmd.MarkFlagRequired("environment")
+ _ = environmentEnvCreateCmd.MarkFlagRequired("key")
+ _ = environmentEnvCreateCmd.MarkFlagRequired("value")
+}
diff --git a/cmd/environment_env_delete.go b/cmd/environment_env_delete.go
new file mode 100644
index 00000000..c7a08dea
--- /dev/null
+++ b/cmd/environment_env_delete.go
@@ -0,0 +1,61 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentEnvDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ checkError(err)
+
+ environment := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+
+ if environment == nil {
+ utils.PrintlnError(fmt.Errorf("environment %s not found", environmentName))
+ utils.PrintlnInfo("You can list all environments with: qovery environment list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteEnvironmentVar(client, environment.Id, utils.Key)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("Variable %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ environmentEnvCmd.AddCommand(environmentEnvDeleteCmd)
+ environmentEnvDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentEnvDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentEnvDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentEnvDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+
+ _ = environmentEnvDeleteCmd.MarkFlagRequired("project")
+ _ = environmentEnvDeleteCmd.MarkFlagRequired("environment")
+ _ = environmentEnvDeleteCmd.MarkFlagRequired("key")
+}
diff --git a/cmd/environment_env_list.go b/cmd/environment_env_list.go
new file mode 100644
index 00000000..dd61e943
--- /dev/null
+++ b/cmd/environment_env_list.go
@@ -0,0 +1,77 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentEnvListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List environment variables",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ checkError(err)
+
+ environment := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+
+ if environment == nil {
+ utils.PrintlnError(fmt.Errorf("environment %s not found", environmentName))
+ utils.PrintlnInfo("You can list all environments with: qovery environment list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVars, err := utils.ListEnvironmentVariables(client, environment.Id)
+ checkError(err)
+
+ envVarLines := utils.NewEnvVarLines()
+ var variables []utils.EnvVarLineOutput
+
+ for _, envVar := range envVars {
+ s := utils.FromEnvironmentVariableToEnvVarLineOutput(envVar)
+ variables = append(variables, s)
+ envVarLines.Add(s)
+ }
+
+ if jsonFlag {
+ utils.Println(utils.GetEnvVarJsonOutput(variables, utils.SortKeys))
+ return
+ }
+
+ err = utils.PrintTable(envVarLines.Header(utils.PrettyPrint), envVarLines.Lines(utils.ShowValues, utils.PrettyPrint, utils.SortKeys))
+ checkError(err)
+ },
+}
+
+func init() {
+ environmentEnvCmd.AddCommand(environmentEnvListCmd)
+ environmentEnvListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentEnvListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentEnvListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentEnvListCmd.Flags().BoolVarP(&utils.ShowValues, "show-values", "", false, "Show env var values")
+ environmentEnvListCmd.Flags().BoolVarP(&utils.PrettyPrint, "pretty-print", "", false, "Pretty print output")
+ environmentEnvListCmd.Flags().BoolVarP(&utils.SortKeys, "sort", "", false, "Sort environment variables by key")
+ environmentEnvListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = environmentEnvListCmd.MarkFlagRequired("project")
+ _ = environmentEnvListCmd.MarkFlagRequired("environment")
+}
diff --git a/cmd/environment_env_override.go b/cmd/environment_env_override.go
new file mode 100644
index 00000000..8138d5bc
--- /dev/null
+++ b/cmd/environment_env_override.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentEnvOverrideCmd = &cobra.Command{
+ Use: "override",
+ Short: "Manage environment variable and secret overrides",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ environmentEnvCmd.AddCommand(environmentEnvOverrideCmd)
+}
diff --git a/cmd/environment_env_override_create.go b/cmd/environment_env_override_create.go
new file mode 100644
index 00000000..1e34df64
--- /dev/null
+++ b/cmd/environment_env_override_create.go
@@ -0,0 +1,65 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentEnvOverrideCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Override environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ checkError(err)
+
+ environment := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+
+ if environment == nil {
+ utils.PrintlnError(fmt.Errorf("environment %s not found", environmentName))
+ utils.PrintlnInfo("You can list all environments with: qovery environment list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateEnvironmentOverride(client, projectId, environment.Id, utils.Key, utils.Value, utils.EnvironmentScope)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("%s has been overidden", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ environmentEnvOverrideCmd.AddCommand(environmentEnvOverrideCreateCmd)
+ environmentEnvOverrideCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentEnvOverrideCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentEnvOverrideCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentEnvOverrideCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ environmentEnvOverrideCreateCmd.Flags().StringVarP(&utils.Value, "value", "", "", "Environment variable or secret value")
+ environmentEnvOverrideCreateCmd.Flags().StringVarP(&utils.EnvironmentScope, "scope", "", "ENVIRONMENT", "Scope of this alias ")
+
+ _ = environmentEnvOverrideCreateCmd.MarkFlagRequired("project")
+ _ = environmentEnvOverrideCreateCmd.MarkFlagRequired("environment")
+ _ = environmentEnvOverrideCreateCmd.MarkFlagRequired("key")
+ _ = environmentEnvOverrideCreateCmd.MarkFlagRequired("value")
+}
diff --git a/cmd/environment_env_update.go b/cmd/environment_env_update.go
new file mode 100644
index 00000000..54499b26
--- /dev/null
+++ b/cmd/environment_env_update.go
@@ -0,0 +1,63 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentEnvUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ checkError(err)
+
+ environment := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+
+ if environment == nil {
+ utils.PrintlnError(fmt.Errorf("environment %s not found", environmentName))
+ utils.PrintlnInfo("You can list all environments with: qovery environment list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateEnvironmentVariable(client, environment.Id, utils.Key, utils.Value)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ environmentEnvCmd.AddCommand(environmentEnvUpdateCmd)
+ environmentEnvUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentEnvUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentEnvUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentEnvUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ environmentEnvUpdateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+
+ _ = environmentEnvUpdateCmd.MarkFlagRequired("project")
+ _ = environmentEnvUpdateCmd.MarkFlagRequired("environment")
+ _ = environmentEnvUpdateCmd.MarkFlagRequired("key")
+ _ = environmentEnvUpdateCmd.MarkFlagRequired("value")
+}
diff --git a/cmd/environment_external_secret.go b/cmd/environment_external_secret.go
new file mode 100644
index 00000000..26c24df9
--- /dev/null
+++ b/cmd/environment_external_secret.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var environmentExternalSecretCmd = &cobra.Command{
+ Use: "external-secret",
+ Short: "Manage environment external secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentExternalSecretCmd)
+}
diff --git a/cmd/environment_external_secret_create.go b/cmd/environment_external_secret_create.go
new file mode 100644
index 00000000..9b40c9c8
--- /dev/null
+++ b/cmd/environment_external_secret_create.go
@@ -0,0 +1,76 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentExternalSecretCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create environment external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+ if project == nil {
+ utils.PrintlnError(fmt.Errorf("project %s not found", projectName))
+ utils.PrintlnInfo("You can list all projects with: qovery project list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ checkError(err)
+
+ environment := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+ if environment == nil {
+ utils.PrintlnError(fmt.Errorf("environment %s not found", environmentName))
+ utils.PrintlnInfo("You can list all environments with: qovery environment list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, environment.Id, utils.SecretManagerAccessName)
+ checkError(err)
+
+ err = utils.CreateServiceExternalSecret(client, project.Id, environment.Id, "", utils.EnvironmentScope, utils.Key, utils.Reference, secretManagerAccessId, utils.MountPath)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("External secret %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ environmentExternalSecretCmd.AddCommand(environmentExternalSecretCreateCmd)
+ environmentExternalSecretCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentExternalSecretCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentExternalSecretCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentExternalSecretCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ environmentExternalSecretCreateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "Reference to the secret in the secrets provider")
+ environmentExternalSecretCreateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "Secret manager access name")
+ environmentExternalSecretCreateCmd.Flags().StringVarP(&utils.EnvironmentScope, "scope", "", "ENVIRONMENT", "Scope of this external secret ")
+ environmentExternalSecretCreateCmd.Flags().StringVarP(&utils.MountPath, "mount-path", "", "", "Path where the secret will be mounted as a file")
+
+ _ = environmentExternalSecretCreateCmd.MarkFlagRequired("project")
+ _ = environmentExternalSecretCreateCmd.MarkFlagRequired("environment")
+ _ = environmentExternalSecretCreateCmd.MarkFlagRequired("key")
+ _ = environmentExternalSecretCreateCmd.MarkFlagRequired("reference")
+ _ = environmentExternalSecretCreateCmd.MarkFlagRequired("secret-manager-access-name")
+}
diff --git a/cmd/environment_external_secret_delete.go b/cmd/environment_external_secret_delete.go
new file mode 100644
index 00000000..d54d9156
--- /dev/null
+++ b/cmd/environment_external_secret_delete.go
@@ -0,0 +1,67 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentExternalSecretDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete environment external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+ if project == nil {
+ utils.PrintlnError(fmt.Errorf("project %s not found", projectName))
+ utils.PrintlnInfo("You can list all projects with: qovery project list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ checkError(err)
+
+ environment := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+ if environment == nil {
+ utils.PrintlnError(fmt.Errorf("environment %s not found", environmentName))
+ utils.PrintlnInfo("You can list all environments with: qovery environment list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteEnvironmentVar(client, environment.Id, utils.Key)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("External secret %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ environmentExternalSecretCmd.AddCommand(environmentExternalSecretDeleteCmd)
+ environmentExternalSecretDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentExternalSecretDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentExternalSecretDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentExternalSecretDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+
+ _ = environmentExternalSecretDeleteCmd.MarkFlagRequired("project")
+ _ = environmentExternalSecretDeleteCmd.MarkFlagRequired("environment")
+ _ = environmentExternalSecretDeleteCmd.MarkFlagRequired("key")
+}
diff --git a/cmd/environment_external_secret_update.go b/cmd/environment_external_secret_update.go
new file mode 100644
index 00000000..3b6834dc
--- /dev/null
+++ b/cmd/environment_external_secret_update.go
@@ -0,0 +1,72 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentExternalSecretUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update environment external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+ if project == nil {
+ utils.PrintlnError(fmt.Errorf("project %s not found", projectName))
+ utils.PrintlnInfo("You can list all projects with: qovery project list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ checkError(err)
+
+ environment := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+ if environment == nil {
+ utils.PrintlnError(fmt.Errorf("environment %s not found", environmentName))
+ utils.PrintlnInfo("You can list all environments with: qovery environment list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, environment.Id, utils.SecretManagerAccessName)
+ checkError(err)
+
+ err = utils.UpdateEnvironmentExternalSecret(client, environment.Id, utils.Key, utils.Reference, secretManagerAccessId)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("External secret %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ environmentExternalSecretCmd.AddCommand(environmentExternalSecretUpdateCmd)
+ environmentExternalSecretUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentExternalSecretUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentExternalSecretUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentExternalSecretUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ environmentExternalSecretUpdateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "New reference to the secret in the secrets provider")
+ environmentExternalSecretUpdateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "New secret manager access name")
+
+ _ = environmentExternalSecretUpdateCmd.MarkFlagRequired("project")
+ _ = environmentExternalSecretUpdateCmd.MarkFlagRequired("environment")
+ _ = environmentExternalSecretUpdateCmd.MarkFlagRequired("key")
+}
diff --git a/cmd/environment_list.go b/cmd/environment_list.go
new file mode 100644
index 00000000..02dc5789
--- /dev/null
+++ b/cmd/environment_list.go
@@ -0,0 +1,105 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/qovery/qovery-client-go"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var environmentListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List environments",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, err := getOrganizationProjectContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), projectId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ statuses, _, err := client.EnvironmentsAPI.GetProjectEnvironmentsStatus(context.Background(), projectId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(getEnvironmentJsonOutput(statuses.GetResults(), environments.GetResults()))
+ return
+ }
+
+ var data [][]string
+
+ for _, env := range environments.GetResults() {
+ data = append(data, []string{env.Id, env.GetName(), *env.ClusterName, string(env.Mode),
+ utils.GetEnvironmentStatusWithColor(statuses.GetResults(), env.Id), env.UpdatedAt.String()})
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name", "Cluster", "Type", "Status", "Last Update"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getEnvironmentJsonOutput(statuses []qovery.EnvironmentStatus, environments []qovery.Environment) string {
+ var results []interface{}
+
+ for _, env := range environments {
+ results = append(results, map[string]interface{}{
+ "id": env.Id,
+ "created_at": utils.ToIso8601(&env.CreatedAt),
+ "updated_at": utils.ToIso8601(env.UpdatedAt),
+ "name": env.GetName(),
+ "cluster_name": *env.ClusterName,
+ "cluster_id": env.ClusterId,
+ "type": string(env.Mode),
+ "status": utils.GetEnvironmentStatus(statuses, env.Id),
+ })
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentListCmd)
+ environmentListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/environment_redeploy.go b/cmd/environment_redeploy.go
new file mode 100644
index 00000000..00ce16fc
--- /dev/null
+++ b/cmd/environment_redeploy.go
@@ -0,0 +1,37 @@
+package cmd
+
+import (
+ "context"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "time"
+)
+
+var environmentRedeployCmd = &cobra.Command{
+ Use: "redeploy",
+ Short: "Redeploy an environment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+ _, _, err := client.EnvironmentActionsAPI.
+ DeployEnvironment(context.Background(), envId).
+ Execute()
+ checkError(err)
+ utils.Println("Request to redeploy environment has been queued..")
+ if watchFlag {
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent from race condition)
+ utils.WatchEnvironment(envId, qovery.STATEENUM_DEPLOYED, client)
+ }
+ },
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentRedeployCmd)
+ environmentRedeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentRedeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentRedeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentRedeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch environment status until it's ready or an error occurs")
+}
diff --git a/cmd/environment_stage.go b/cmd/environment_stage.go
new file mode 100644
index 00000000..eb14aa1c
--- /dev/null
+++ b/cmd/environment_stage.go
@@ -0,0 +1,29 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var stageName string
+var serviceName string
+var newStageName string
+var stageDescription string
+
+var environmentStageCmd = &cobra.Command{
+ Use: "stage",
+ Short: "Manage deployment stages",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentStageCmd)
+}
diff --git a/cmd/environment_stage_create.go b/cmd/environment_stage_create.go
new file mode 100644
index 00000000..6d90e319
--- /dev/null
+++ b/cmd/environment_stage_create.go
@@ -0,0 +1,65 @@
+package cmd
+
+import (
+ "context"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var environmentStageCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create deployment stage",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, environmentId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ req := qovery.DeploymentStageRequest{
+ Name: stageName,
+ }
+
+ desc := qovery.NullableString{}
+ desc.Set(&stageDescription)
+
+ if stageDescription != "" {
+ req.Description = desc
+ }
+
+ _, _, err = client.DeploymentStageMainCallsAPI.CreateEnvironmentDeploymentStage(context.Background(), environmentId).DeploymentStageRequest(req).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println("StageLevel created successfully")
+ },
+}
+
+func init() {
+ environmentStageCmd.AddCommand(environmentStageCreateCmd)
+ environmentStageCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentStageCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentStageCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentStageCreateCmd.Flags().StringVarP(&stageName, "name", "n", "", "StageLevel Name")
+ environmentStageCreateCmd.Flags().StringVarP(&stageDescription, "description", "d", "", "StageLevel Description")
+
+ _ = environmentStageCreateCmd.MarkFlagRequired("name")
+}
diff --git a/cmd/environment_stage_delete.go b/cmd/environment_stage_delete.go
new file mode 100644
index 00000000..de5e231d
--- /dev/null
+++ b/cmd/environment_stage_delete.go
@@ -0,0 +1,81 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var environmentStageDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete deployment stage",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, environmentId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ stages, _, err := client.DeploymentStageMainCallsAPI.ListEnvironmentDeploymentStage(context.Background(), environmentId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ stage, err := GetStageByName(stages.GetResults(), stageName)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ _, err = client.DeploymentStageMainCallsAPI.DeleteDeploymentStage(context.Background(), stage.GetId()).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println("StageLevel deleted successfully")
+ },
+}
+
+func GetStageByName(stages []qovery.DeploymentStageResponse, stageName string) (*qovery.DeploymentStageResponse, error) {
+ for _, stage := range stages {
+ if stage.GetName() == stageName {
+ return &stage, nil
+ }
+ }
+
+ return nil, errors.New("stage not found")
+}
+
+func init() {
+ environmentStageCmd.AddCommand(environmentStageDeleteCmd)
+ environmentStageDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentStageDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentStageDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentStageDeleteCmd.Flags().StringVarP(&stageName, "name", "n", "", "StageLevel Name")
+
+ _ = environmentStageDeleteCmd.MarkFlagRequired("name")
+
+}
diff --git a/cmd/environment_stage_edit.go b/cmd/environment_stage_edit.go
new file mode 100644
index 00000000..31e13fce
--- /dev/null
+++ b/cmd/environment_stage_edit.go
@@ -0,0 +1,83 @@
+package cmd
+
+import (
+ "context"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var environmentStageEditCmd = &cobra.Command{
+ Use: "edit",
+ Short: "Edit deployment stage",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, environmentId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ stages, _, err := client.DeploymentStageMainCallsAPI.ListEnvironmentDeploymentStage(context.Background(), environmentId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ stage, err := GetStageByName(stages.GetResults(), stageName)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ req := qovery.DeploymentStageRequest{
+ Name: newStageName,
+ }
+
+ desc := qovery.NullableString{}
+ desc.Set(&stageDescription)
+
+ if stageDescription != "" {
+ req.Description = desc
+ }
+
+ _, _, err = client.DeploymentStageMainCallsAPI.EditDeploymentStage(context.Background(), stage.GetId()).DeploymentStageRequest(req).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println("StageLevel updated successfully")
+ },
+}
+
+func init() {
+ environmentStageCmd.AddCommand(environmentStageEditCmd)
+ environmentStageEditCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentStageEditCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentStageEditCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentStageEditCmd.Flags().StringVarP(&stageName, "name", "n", "", "StageLevel Name")
+ environmentStageEditCmd.Flags().StringVarP(&newStageName, "new-name", "", "", "New StageLevel Name")
+ environmentStageEditCmd.Flags().StringVarP(&stageDescription, "new-description", "", "", "New StageLevel Description")
+
+ _ = environmentStageEditCmd.MarkFlagRequired("name")
+ _ = environmentStageEditCmd.MarkFlagRequired("new-name")
+}
diff --git a/cmd/environment_stage_list.go b/cmd/environment_stage_list.go
new file mode 100644
index 00000000..46db5e54
--- /dev/null
+++ b/cmd/environment_stage_list.go
@@ -0,0 +1,146 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "strconv"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var environmentStageListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List deployment stages",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ utils.CheckError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, environmentId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ utils.CheckError(err)
+
+ stages, _, err := client.DeploymentStageMainCallsAPI.ListEnvironmentDeploymentStage(context.Background(), environmentId).Execute()
+ utils.CheckError(err)
+
+ if jsonFlag {
+ utils.Println(getEnvironmentStageJsonOutput(*client, stages.GetResults()))
+ return
+ }
+
+ // Collect all skipped services across all stages
+ var skippedData [][]string
+ for _, stage := range stages.GetResults() {
+ for _, service := range stage.GetServices() {
+ if service.GetIsSkipped() {
+ skippedData = append(skippedData, []string{
+ service.Id,
+ service.GetServiceType(),
+ utils.GetServiceNameByIdAndType(client, service.GetServiceId(), service.GetServiceType()),
+ stage.GetName(),
+ })
+ }
+ }
+ }
+
+ // Show skipped services section first
+ if len(skippedData) > 0 {
+ pterm.DefaultSection.WithBottomPadding(0).Println("Skipped services (excluded from environment-level deployments)")
+ utils.Println("")
+ err = utils.PrintTable([]string{"Id", "Type", "Name", "Stage"}, skippedData)
+ utils.Println("")
+ utils.CheckError(err)
+ }
+
+ // Show each stage with only non-skipped services
+ for _, stage := range stages.GetResults() {
+ pterm.DefaultSection.WithBottomPadding(0).Println("deployment stage " + strconv.Itoa(int(stage.GetDeploymentOrder()+1)) + ": \"" + stage.GetName() + "\"")
+ utils.Println("Stage id: " + stage.GetId())
+ if stage.GetDescription() != "" {
+ utils.Println(stage.GetDescription())
+ }
+
+ utils.Println("")
+
+ var data [][]string
+ for _, service := range stage.GetServices() {
+ if !service.GetIsSkipped() {
+ data = append(data, []string{
+ service.Id,
+ service.GetServiceType(),
+ utils.GetServiceNameByIdAndType(client, service.GetServiceId(), service.GetServiceType()),
+ })
+ }
+ }
+
+ if len(data) == 0 {
+ if len(stage.GetServices()) == 0 {
+ utils.Println("")
+ } else {
+ utils.Println("")
+ }
+ } else {
+ err = utils.PrintTable([]string{"Id", "Type", "Name"}, data)
+ utils.CheckError(err)
+ }
+
+ utils.Println("")
+ }
+ },
+}
+
+func getEnvironmentStageJsonOutput(client qovery.APIClient, stages []qovery.DeploymentStageResponse) string {
+ var skippedServices []interface{}
+ var results []interface{}
+
+ for idx, stage := range stages {
+ var services []interface{}
+
+ for _, service := range stage.Services {
+ entry := map[string]interface{}{
+ "id": service.ServiceId,
+ "type": service.ServiceType,
+ "name": utils.GetServiceNameByIdAndType(&client, service.GetServiceId(), service.GetServiceType()),
+ "is_skipped": service.GetIsSkipped(),
+ }
+ services = append(services, entry)
+
+ if service.GetIsSkipped() {
+ skippedServices = append(skippedServices, map[string]interface{}{
+ "id": service.ServiceId,
+ "type": service.ServiceType,
+ "name": utils.GetServiceNameByIdAndType(&client, service.GetServiceId(), service.GetServiceType()),
+ "stage": stage.Name,
+ })
+ }
+ }
+
+ results = append(results, map[string]interface{}{
+ "stage_order": idx + 1,
+ "stage_id": stage.Id,
+ "stage_name": stage.Name,
+ "stage_description": stage.Description,
+ "services": services,
+ })
+ }
+
+ j, err := json.Marshal(map[string]interface{}{
+ "skipped_services": skippedServices,
+ "stages": results,
+ })
+ utils.CheckError(err)
+
+ return string(j)
+}
+
+func init() {
+ environmentStageCmd.AddCommand(environmentStageListCmd)
+ environmentStageListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentStageListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentStageListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentStageListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/environment_stage_move.go b/cmd/environment_stage_move.go
new file mode 100644
index 00000000..175e47ab
--- /dev/null
+++ b/cmd/environment_stage_move.go
@@ -0,0 +1,145 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var environmentStageMoveCmd = &cobra.Command{
+ Use: "move",
+ Short: "Move service into deployment stage",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, environmentId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ stages, _, err := client.DeploymentStageMainCallsAPI.ListEnvironmentDeploymentStage(context.Background(), environmentId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ var service *qovery.DeploymentStageServiceResponse
+ for _, stage := range stages.GetResults() {
+ service, _ = getServiceByName(client, stage.GetServices(), serviceName)
+
+ if service != nil {
+ break
+ }
+ }
+
+ if service == nil {
+ utils.PrintlnError(errors.New("service not found"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ stage, err := GetStageByName(stages.GetResults(), stageName)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ req := qovery.DeploymentStageRequest{
+ Name: newStageName,
+ }
+
+ desc := qovery.NullableString{}
+ desc.Set(&stageDescription)
+
+ if stageDescription != "" {
+ req.Description = desc
+ }
+
+ _, _, err = client.DeploymentStageMainCallsAPI.AttachServiceToDeploymentStage(context.Background(), stage.GetId(), service.GetServiceId()).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println("Application moved into stage \"" + stageName + "\"")
+ },
+}
+
+func getServiceByName(client *qovery.APIClient, services []qovery.DeploymentStageServiceResponse, name string) (*qovery.DeploymentStageServiceResponse, error) {
+ for _, service := range services {
+ switch service.GetServiceType() {
+ case "APPLICATION":
+ application, _, err := client.ApplicationMainCallsAPI.GetApplication(context.Background(), service.GetServiceId()).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ if application.GetName() == name {
+ return &service, nil
+ }
+ case "DATABASE":
+ database, _, err := client.DatabaseMainCallsAPI.GetDatabase(context.Background(), service.GetServiceId()).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ if database.GetName() == name {
+ return &service, nil
+ }
+ case "CONTAINER":
+ container, _, err := client.ContainerMainCallsAPI.GetContainer(context.Background(), service.GetServiceId()).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ if container.GetName() == name {
+ return &service, nil
+ }
+ case "JOB":
+ job, _, err := client.JobMainCallsAPI.GetJob(context.Background(), service.GetServiceId()).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ if utils.GetJobName(job) == name {
+ return &service, nil
+ }
+ default:
+ return nil, errors.New("service type not found")
+ }
+ }
+
+ return nil, errors.New("service not found")
+}
+
+func init() {
+ environmentStageCmd.AddCommand(environmentStageMoveCmd)
+ environmentStageMoveCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentStageMoveCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentStageMoveCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentStageMoveCmd.Flags().StringVarP(&serviceName, "name", "n", "", "ServiceLevel Name")
+ environmentStageMoveCmd.Flags().StringVarP(&stageName, "stage", "s", "", "Target StageLevel Name")
+
+ _ = environmentStageMoveCmd.MarkFlagRequired("name")
+ _ = environmentStageMoveCmd.MarkFlagRequired("stage")
+}
diff --git a/cmd/environment_stage_skip.go b/cmd/environment_stage_skip.go
new file mode 100644
index 00000000..e6ae1735
--- /dev/null
+++ b/cmd/environment_stage_skip.go
@@ -0,0 +1,64 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var environmentStageSkipCmd = &cobra.Command{
+ Use: "skip",
+ Short: "Skip service from environment-level deployments",
+ Long: "Mark a service as skipped so it is excluded from environment-level bulk deployments while staying in its current stage. To reverse this, use 'environment stage unskip' or move the service to a different deployment stage, which automatically clears the skipped status.",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ utils.CheckError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, environmentId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ utils.CheckError(err)
+
+ stages, _, err := client.DeploymentStageMainCallsAPI.ListEnvironmentDeploymentStage(context.Background(), environmentId).Execute()
+ utils.CheckError(err)
+
+ var service *qovery.DeploymentStageServiceResponse
+ var currentStageId string
+ for _, stage := range stages.GetResults() {
+ service, _ = getServiceByName(client, stage.GetServices(), serviceName)
+ if service != nil {
+ currentStageId = stage.GetId()
+ break
+ }
+ }
+
+ if service == nil {
+ utils.CheckError(errors.New("service not found"))
+ }
+
+ req := qovery.AttachServiceToDeploymentStageRequest{}
+ req.SetIsSkipped(true)
+
+ _, _, err = client.DeploymentStageMainCallsAPI.
+ AttachServiceToDeploymentStage(context.Background(), currentStageId, service.GetServiceId()).
+ AttachServiceToDeploymentStageRequest(req).
+ Execute()
+ utils.CheckError(err)
+
+ utils.Println("Service \"" + serviceName + "\" is now skipped from environment-level deployments")
+ },
+}
+
+func init() {
+ environmentStageCmd.AddCommand(environmentStageSkipCmd)
+ environmentStageSkipCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentStageSkipCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentStageSkipCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentStageSkipCmd.Flags().StringVarP(&serviceName, "name", "n", "", "Service Name")
+
+ _ = environmentStageSkipCmd.MarkFlagRequired("name")
+}
diff --git a/cmd/environment_stage_unskip.go b/cmd/environment_stage_unskip.go
new file mode 100644
index 00000000..6e6e6ae8
--- /dev/null
+++ b/cmd/environment_stage_unskip.go
@@ -0,0 +1,64 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var environmentStageUnskipCmd = &cobra.Command{
+ Use: "unskip",
+ Short: "Unskip service from environment-level deployments",
+ Long: "Remove the skipped flag from a service so it is included again in environment-level bulk deployments.",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ utils.CheckError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, environmentId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ utils.CheckError(err)
+
+ stages, _, err := client.DeploymentStageMainCallsAPI.ListEnvironmentDeploymentStage(context.Background(), environmentId).Execute()
+ utils.CheckError(err)
+
+ var service *qovery.DeploymentStageServiceResponse
+ var currentStageId string
+ for _, stage := range stages.GetResults() {
+ service, _ = getServiceByName(client, stage.GetServices(), serviceName)
+ if service != nil {
+ currentStageId = stage.GetId()
+ break
+ }
+ }
+
+ if service == nil {
+ utils.CheckError(errors.New("service not found"))
+ }
+
+ req := qovery.AttachServiceToDeploymentStageRequest{}
+ req.SetIsSkipped(false)
+
+ _, _, err = client.DeploymentStageMainCallsAPI.
+ AttachServiceToDeploymentStage(context.Background(), currentStageId, service.GetServiceId()).
+ AttachServiceToDeploymentStageRequest(req).
+ Execute()
+ utils.CheckError(err)
+
+ utils.Println("Service \"" + serviceName + "\" is no longer skipped from environment-level deployments")
+ },
+}
+
+func init() {
+ environmentStageCmd.AddCommand(environmentStageUnskipCmd)
+ environmentStageUnskipCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentStageUnskipCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentStageUnskipCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentStageUnskipCmd.Flags().StringVarP(&serviceName, "name", "n", "", "Service Name")
+
+ _ = environmentStageUnskipCmd.MarkFlagRequired("name")
+}
diff --git a/cmd/environment_statuses.go b/cmd/environment_statuses.go
new file mode 100644
index 00000000..53cd0ae8
--- /dev/null
+++ b/cmd/environment_statuses.go
@@ -0,0 +1,114 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var environmentServicesStatusesCmd = &cobra.Command{
+ Use: "statuses",
+ Short: "Get environment services statuses",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ // Get env and services statuses
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ j, err := json.Marshal(statuses)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ utils.Println(string(j))
+ return
+ }
+
+
+ var data [][]string
+ for _, status := range statuses.Applications {
+ data = append(data, []string{
+ "application",
+ status.Id,
+ string(status.GetState()),
+ })
+ }
+ for _, status := range statuses.Containers {
+ data = append(data, []string{
+ "container",
+ status.Id,
+ string(status.GetState()),
+ })
+ }
+ for _, status := range statuses.Helms {
+ data = append(data, []string{
+ "helm",
+ status.Id,
+ string(status.GetState()),
+ })
+ }
+ for _, status := range statuses.Jobs {
+ data = append(data, []string{
+ "job",
+ status.Id,
+ string(status.GetState()),
+ })
+ }
+ for _, status := range statuses.Databases {
+ data = append(data, []string{
+ "database",
+ status.Id,
+ string(status.GetState()),
+ })
+ }
+
+ utils.Println(fmt.Sprintf("\nEnvironment status: %s \n", statuses.Environment.GetState()))
+ err = utils.PrintTable([]string{
+ "Type",
+ "ID",
+ "Status",
+ }, data)
+
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("cannot print services statuses: %s", err))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentServicesStatusesCmd)
+ environmentServicesStatusesCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentServicesStatusesCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentServicesStatusesCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentServicesStatusesCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/environment_stop.go b/cmd/environment_stop.go
new file mode 100644
index 00000000..32d0da59
--- /dev/null
+++ b/cmd/environment_stop.go
@@ -0,0 +1,41 @@
+package cmd
+
+import (
+ "context"
+ "time"
+
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var environmentStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop an environment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ _, _, err := client.EnvironmentActionsAPI.
+ StopEnvironment(context.Background(), envId).
+ Execute()
+ checkError(err)
+ utils.Println("Environment stop request has been queued..")
+
+ if watchFlag {
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent from race condition)
+ utils.WatchEnvironment(envId, qovery.STATEENUM_STOPPED, client)
+ }
+ },
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentStopCmd)
+ environmentStopCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ environmentStopCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ environmentStopCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ environmentStopCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch environment status until it's ready or an error occurs")
+}
diff --git a/cmd/environment_update.go b/cmd/environment_update.go
new file mode 100644
index 00000000..5511735e
--- /dev/null
+++ b/cmd/environment_update.go
@@ -0,0 +1,100 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var environmentUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update an environment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, err := getOrganizationProjectContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), projectId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ env := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+
+ if env == nil {
+ utils.PrintlnError(fmt.Errorf("environment %s not found", environmentName))
+ utils.PrintlnInfo("You can list all environments with: qovery environment list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ m := getEnvironmentType(string(env.Mode))
+ req := qovery.EnvironmentEditRequest{
+ Name: &env.Name,
+ Mode: &m,
+ }
+
+ if newEnvironmentName != "" {
+ req.Name = &newEnvironmentName
+ }
+
+ if environmentType != "" {
+ m = getEnvironmentType(environmentType)
+ req.Mode = &m
+ }
+
+ _, _, err = client.EnvironmentMainCallsAPI.EditEnvironment(context.Background(), env.Id).EnvironmentEditRequest(req).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println("Environment is updated!")
+ },
+}
+
+func getEnvironmentType(environmentType string) qovery.CreateEnvironmentModeEnum {
+ switch strings.ToUpper(environmentType) {
+ case "DEVELOPMENT":
+ return qovery.CREATEENVIRONMENTMODEENUM_DEVELOPMENT
+ case "PRODUCTION":
+ return qovery.CREATEENVIRONMENTMODEENUM_PRODUCTION
+ case "STAGING":
+ return qovery.CREATEENVIRONMENTMODEENUM_STAGING
+ }
+
+ return qovery.CREATEENVIRONMENTMODEENUM_DEVELOPMENT
+}
+
+func init() {
+ environmentCmd.AddCommand(environmentUpdateCmd)
+ environmentUpdateCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ environmentUpdateCmd.Flags().StringVarP(&projectName, "project", "p", "", "Project Name")
+ environmentUpdateCmd.Flags().StringVarP(&environmentName, "environment", "e", "", "Environment Name")
+ environmentUpdateCmd.Flags().StringVarP(&newEnvironmentName, "name", "", "", "New Environment Name")
+ environmentUpdateCmd.Flags().StringVarP(&environmentType, "type", "", "", "Change Environment Type (DEVELOPMENT|STAGING|PRODUCTION)")
+}
diff --git a/cmd/external_secret_helpers.go b/cmd/external_secret_helpers.go
new file mode 100644
index 00000000..4f9d6d50
--- /dev/null
+++ b/cmd/external_secret_helpers.go
@@ -0,0 +1,40 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/qovery/qovery-client-go"
+ "github.com/qovery/qovery-cli/pkg/cluster"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+)
+
+func getSecretManagerAccessIdByName(client *qovery.APIClient, organizationId, envId, name string) (string, error) {
+ env, _, err := client.EnvironmentMainCallsAPI.GetEnvironment(context.Background(), envId).Execute()
+ if err != nil {
+ return "", fmt.Errorf("failed to get environment: %w", err)
+ }
+
+ clusters, err := cluster.NewClusterService(client, &promptuifactory.PromptUiFactoryImpl{}).ListClusters(organizationId)
+ if err != nil {
+ return "", fmt.Errorf("failed to list clusters: %w", err)
+ }
+
+ var matchedCluster *qovery.Cluster
+ for i, c := range clusters.GetResults() {
+ if c.Id == env.ClusterId {
+ matchedCluster = &clusters.GetResults()[i]
+ break
+ }
+ }
+ if matchedCluster == nil {
+ return "", fmt.Errorf("cluster %s not found in organization", env.ClusterId)
+ }
+
+ for _, sma := range matchedCluster.SecretManagerAccesses {
+ if sma.Name == name {
+ return sma.Id, nil
+ }
+ }
+ return "", fmt.Errorf("secret manager access %q not found in cluster %s", name, matchedCluster.Name)
+}
diff --git a/cmd/helm.go b/cmd/helm.go
new file mode 100644
index 00000000..fea0884b
--- /dev/null
+++ b/cmd/helm.go
@@ -0,0 +1,35 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var helmName string
+var helmNames string
+var targetHelmName string
+var chartVersion string
+var chartName string
+var chartGitCommitId string
+var charGitCommitBranch string
+var valuesOverrideCommitId string
+var valuesOverrideCommitBranch string
+var helmCustomDomain string
+
+var helmCmd = &cobra.Command{
+ Use: "helm",
+ Short: "Manage helms",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(helmCmd)
+}
diff --git a/cmd/helm_cancel.go b/cmd/helm_cancel.go
new file mode 100644
index 00000000..da9740cb
--- /dev/null
+++ b/cmd/helm_cancel.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmCancelCmd = &cobra.Command{
+ Use: "cancel",
+ Short: "Cancel a helm deployment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ msg, err := utils.CancelServiceDeployment(client, envId, helm.Id, utils.HelmType, watchFlag)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if msg != "" {
+ utils.PrintlnInfo(msg)
+ return
+ }
+
+ utils.Println(fmt.Sprintf("helm %s deployment cancelled!", pterm.FgBlue.Sprintf("%s", helmName)))
+ },
+}
+
+func init() {
+ helmCmd.AddCommand(helmCancelCmd)
+ helmCancelCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmCancelCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmCancelCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmCancelCmd.Flags().StringVarP(&helmName, "helm", "n", "", "Helm Name")
+ helmCancelCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cancel until it's done or an error occurs")
+
+ _ = helmCancelCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_clone.go b/cmd/helm_clone.go
new file mode 100644
index 00000000..8411a8b5
--- /dev/null
+++ b/cmd/helm_clone.go
@@ -0,0 +1,116 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/go-errors/errors"
+ "github.com/pterm/pterm"
+ "io"
+ "os"
+
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmCloneCmd = &cobra.Command{
+ Use: "clone",
+ Short: "Clone a helm",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm, err := getHelmContextResource(client, helmName, envId)
+
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ targetProjectId := projectId // use same project as the source project
+ if targetProjectName != "" {
+
+ targetProjectId, err = getProjectContextResourceId(client, targetProjectName, organizationId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ targetEnvironmentId := envId // use same env as the source env
+ if targetEnvironmentName != "" {
+
+ targetEnvironmentId, err = getEnvironmentContextResourceId(client, targetEnvironmentName, targetProjectId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ if targetHelmName == "" {
+ // use same helm name as the source helm
+ targetHelmName = helm.Name
+ }
+
+ req := qovery.CloneServiceRequest{
+ Name: targetHelmName,
+ EnvironmentId: targetEnvironmentId,
+ }
+
+ clonedService, res, err := client.HelmsAPI.CloneHelm(context.Background(), helm.Id).CloneServiceRequest(req).Execute()
+
+ if err != nil {
+ // print http body error message
+ if res.StatusCode != 200 {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ }
+
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ name := ""
+ if clonedService != nil {
+ name = clonedService.Name
+ }
+
+ utils.Println(fmt.Sprintf("Helm %s cloned!", pterm.FgBlue.Sprintf("%s", name)))
+ },
+}
+
+func init() {
+ helmCmd.AddCommand(helmCloneCmd)
+ helmCloneCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmCloneCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmCloneCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmCloneCmd.Flags().StringVarP(&helmName, "helm", "n", "", "Helm Name")
+ helmCloneCmd.Flags().StringVarP(&targetProjectName, "target-project", "", "", "Target Project Name")
+ helmCloneCmd.Flags().StringVarP(&targetEnvironmentName, "target-environment", "", "", "Target Environment Name")
+ helmCloneCmd.Flags().StringVarP(&targetHelmName, "target-helm-name", "", "", "Target Helm Name")
+
+ _ = helmCloneCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_container_create.go b/cmd/helm_container_create.go
new file mode 100644
index 00000000..8f2dd8e1
--- /dev/null
+++ b/cmd/helm_container_create.go
@@ -0,0 +1,102 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmDomainCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create helm custom domain",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.HelmCustomDomainAPI.ListHelmCustomDomain(context.Background(), helm.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomain := utils.FindByCustomDomainName(customDomains.GetResults(), helmCustomDomain)
+ if customDomain != nil {
+ utils.PrintlnError(fmt.Errorf("custom domain %s already exists", helmCustomDomain))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ generateCertificate := !doNotGenerateCertificate
+ req := qovery.CustomDomainRequest{
+ Domain: helmCustomDomain,
+ GenerateCertificate: generateCertificate,
+ UseCdn: &useCdn,
+ }
+
+ createdDomain, _, err := client.HelmCustomDomainAPI.CreateHelmCustomDomain(context.Background(), helm.Id).CustomDomainRequest(req).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Custom domain %s has been created (generate certificate: %s)", pterm.FgBlue.Sprintf("%s", createdDomain.Domain), pterm.FgBlue.Sprintf("%s", strconv.FormatBool(createdDomain.GenerateCertificate))))
+ },
+}
+
+func init() {
+ helmDomainCmd.AddCommand(helmDomainCreateCmd)
+ helmDomainCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmDomainCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmDomainCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmDomainCreateCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmDomainCreateCmd.Flags().StringVarP(&helmCustomDomain, "domain", "", "", "Custom Domain ")
+ helmDomainCreateCmd.Flags().BoolVarP(&doNotGenerateCertificate, "do-not-generate-certificate", "", false, "Do Not Generate Certificate")
+ helmDomainCreateCmd.Flags().BoolVarP(&useCdn, "is-behind-a-cdn", "", false, "Custom Domain is behind a CDN")
+
+ _ = helmDomainCreateCmd.MarkFlagRequired("helm")
+ _ = helmDomainCreateCmd.MarkFlagRequired("domain")
+}
diff --git a/cmd/helm_delete.go b/cmd/helm_delete.go
new file mode 100644
index 00000000..6aa6c092
--- /dev/null
+++ b/cmd/helm_delete.go
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete a helm",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateHelmArguments(helmName, helmNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ helmList := buildHelmListFromHelmNames(client, envId, helmName, helmNames)
+ _, err := client.EnvironmentActionsAPI.
+ DeleteSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ HelmIds: utils.Map(helmList, func(helm *qovery.HelmResponse) string {
+ return helm.Id
+ }),
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to delete helm(s) %s has been queued...", pterm.FgBlue.Sprintf("%s%s", helmName, helmNames)))
+ WatchHelmDeployment(client, envId, helmList, watchFlag, qovery.STATEENUM_DELETED)
+ },
+}
+
+func init() {
+ helmCmd.AddCommand(helmDeleteCmd)
+ helmDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmDeleteCmd.Flags().StringVarP(&helmName, "helm", "n", "", "Helm Name")
+ helmDeleteCmd.Flags().StringVarP(&helmNames, "helms", "", "", "Helm Names (comma separated) (ex: --helms \"helm1,helm2\")")
+ helmDeleteCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch helm status until it's ready or an error occurs")
+}
diff --git a/cmd/helm_deploy.go b/cmd/helm_deploy.go
new file mode 100644
index 00000000..ab332486
--- /dev/null
+++ b/cmd/helm_deploy.go
@@ -0,0 +1,58 @@
+package cmd
+
+import (
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "time"
+)
+
+var helmDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy a helm",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateHelmArguments(helmName, helmNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ helmList := buildHelmListFromHelmNames(client, envId, helmName, helmNames)
+ err := utils.DeployHelms(client, envId, helmList, chartVersion, chartGitCommitId, valuesOverrideCommitId)
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to deploy helm(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", helmName, helmNames)))
+ WatchHelmDeployment(client, envId, helmList, watchFlag, qovery.STATEENUM_DEPLOYED)
+ },
+}
+
+func WatchHelmDeployment(
+ client *qovery.APIClient,
+ envId string,
+ helmList []*qovery.HelmResponse,
+ watchFlag bool,
+ finalServiceState qovery.StateEnum,
+) {
+ if watchFlag {
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent from race condition)
+ if len(helmList) == 1 {
+ utils.WatchHelm(helmList[0].Id, envId, client)
+ } else {
+ utils.WatchEnvironment(envId, finalServiceState, client)
+ }
+ }
+}
+
+func init() {
+ helmCmd.AddCommand(helmDeployCmd)
+ helmDeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmDeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmDeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmDeployCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmDeployCmd.Flags().StringVarP(&helmNames, "helms", "", "", "helm Names (comma separated) (ex: --helms \"helm1,helm2\")")
+ helmDeployCmd.Flags().StringVarP(&chartVersion, "chart_version", "", "", "helm chart version")
+ helmDeployCmd.Flags().StringVarP(&chartGitCommitId, "chart_git_commit_id", "", "", "helm chart git commit id")
+ helmDeployCmd.Flags().StringVarP(&valuesOverrideCommitId, "values_override_git_commit_id", "", "", "helm values override git commit id")
+ helmDeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch helm status until it's ready or an error occurs")
+}
diff --git a/cmd/helm_domain.go b/cmd/helm_domain.go
new file mode 100644
index 00000000..915fe933
--- /dev/null
+++ b/cmd/helm_domain.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var helmDomainCmd = &cobra.Command{
+ Use: "domain",
+ Short: "Manage helm domains",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ helmCmd.AddCommand(helmDomainCmd)
+}
diff --git a/cmd/helm_domain_edit.go b/cmd/helm_domain_edit.go
new file mode 100644
index 00000000..a0b8e805
--- /dev/null
+++ b/cmd/helm_domain_edit.go
@@ -0,0 +1,101 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/qovery/qovery-client-go"
+ "os"
+ "strconv"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmDomainEditCmd = &cobra.Command{
+ Use: "edit",
+ Short: "Edit helm custom domain",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.HelmCustomDomainAPI.ListHelmCustomDomain(context.Background(), helm.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomain := utils.FindByCustomDomainName(customDomains.GetResults(), helmCustomDomain)
+ if customDomain == nil {
+ utils.PrintlnError(fmt.Errorf("custom domain %s does not exist", helmCustomDomain))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ generateCertificate := !doNotGenerateCertificate
+ req := qovery.CustomDomainRequest{
+ Domain: helmCustomDomain,
+ GenerateCertificate: generateCertificate,
+ UseCdn: &useCdn,
+ }
+
+ editedDomain, _, err := client.HelmCustomDomainAPI.EditHelmCustomDomain(context.Background(), helm.Id, customDomain.Id).CustomDomainRequest(req).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Custom domain %s has been edited (generate certificate: %s)", pterm.FgBlue.Sprintf("%s", editedDomain.Domain), pterm.FgBlue.Sprintf("%s", strconv.FormatBool(editedDomain.GenerateCertificate))))
+ },
+}
+
+func init() {
+ helmDomainCmd.AddCommand(helmDomainEditCmd)
+ helmDomainEditCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmDomainEditCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmDomainEditCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmDomainEditCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmDomainEditCmd.Flags().StringVarP(&helmCustomDomain, "domain", "", "", "Custom Domain ")
+ helmDomainEditCmd.Flags().BoolVarP(&doNotGenerateCertificate, "do-not-generate-certificate", "", false, "Do Not Generate Certificate")
+ helmDomainEditCmd.Flags().BoolVarP(&useCdn, "is-behind-a-cdn", "", false, "Custom Domain is behind a CDN")
+
+ _ = helmDomainEditCmd.MarkFlagRequired("helm")
+ _ = helmDomainEditCmd.MarkFlagRequired("domain")
+}
diff --git a/cmd/helm_domain_list.go b/cmd/helm_domain_list.go
new file mode 100644
index 00000000..26f9caa9
--- /dev/null
+++ b/cmd/helm_domain_list.go
@@ -0,0 +1,151 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var helmDomainListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List helm domains",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.HelmCustomDomainAPI.ListHelmCustomDomain(context.Background(), helm.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomainsSet := make(map[string]bool)
+ var data [][]string
+
+ for _, customDomain := range customDomains.GetResults() {
+ customDomainsSet[customDomain.Domain] = true
+
+ data = append(data, []string{
+ customDomain.Id,
+ "CUSTOM_DOMAIN",
+ customDomain.Domain,
+ *customDomain.ValidationDomain,
+ strconv.FormatBool(customDomain.GenerateCertificate),
+ })
+ }
+
+ links, _, err := client.HelmMainCallsAPI.ListHelmLinks(context.Background(), helm.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(gethelmDomainJsonOutput(links.GetResults(), customDomains.GetResults()))
+ return
+ }
+
+ for _, link := range links.GetResults() {
+ domain := strings.ReplaceAll(link.Url, "https://", "")
+ if !customDomainsSet[domain] {
+ data = append(data, []string{
+ "N/A",
+ "BUILT_IN_DOMAIN",
+ domain,
+ "N/A",
+ "N/A",
+ })
+ }
+ }
+
+ err = utils.PrintTable([]string{"Id", "Type", "Domain", "Validation Domain", "Generate Certificate"}, data)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func gethelmDomainJsonOutput(links []qovery.Link, domains []qovery.CustomDomain) string {
+ var results []interface{}
+
+ for _, link := range links {
+ results = append(results, map[string]interface{}{
+ "id": nil,
+ "type": "BUILT_IN_DOMAIN",
+ "domain": strings.ReplaceAll(link.Url, "https://", ""),
+ "validation_domain": nil,
+ })
+ }
+
+ for _, domain := range domains {
+ results = append(results, map[string]interface{}{
+ "id": domain.Id,
+ "type": "CUSTOM_DOMAIN",
+ "domain": domain.Domain,
+ "validation_domain": *domain.ValidationDomain,
+ })
+ }
+
+ j, err := json.Marshal(results)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ helmDomainCmd.AddCommand(helmDomainListCmd)
+ helmDomainListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmDomainListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmDomainListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmDomainListCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmDomainListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = helmDomainListCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_env.go b/cmd/helm_env.go
new file mode 100644
index 00000000..07fc19f1
--- /dev/null
+++ b/cmd/helm_env.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var helmEnvCmd = &cobra.Command{
+ Use: "env",
+ Short: "Manage helm environment variables and secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ helmCmd.AddCommand(helmEnvCmd)
+}
diff --git a/cmd/helm_env_alias.go b/cmd/helm_env_alias.go
new file mode 100644
index 00000000..1f34c958
--- /dev/null
+++ b/cmd/helm_env_alias.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var helmEnvAliasCmd = &cobra.Command{
+ Use: "alias",
+ Short: "Manage helm environment variable and secret aliases",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ helmEnvCmd.AddCommand(helmEnvAliasCmd)
+}
diff --git a/cmd/helm_env_alias_create.go b/cmd/helm_env_alias_create.go
new file mode 100644
index 00000000..33345a09
--- /dev/null
+++ b/cmd/helm_env_alias_create.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmEnvAliasCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create helm environment variable or secret alias",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceAlias(client, projectId, envId, helm.Id, utils.HelmType, utils.Key, utils.Alias, utils.HelmScope)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Alias %s has been created", pterm.FgBlue.Sprintf("%s", utils.Alias)))
+ },
+}
+
+func init() {
+ helmEnvAliasCmd.AddCommand(helmEnvAliasCreateCmd)
+ helmEnvAliasCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmEnvAliasCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmEnvAliasCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmEnvAliasCreateCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmEnvAliasCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ helmEnvAliasCreateCmd.Flags().StringVarP(&utils.Alias, "alias", "", "", "Environment variable or secret alias")
+ helmEnvAliasCreateCmd.Flags().StringVarP(&utils.HelmScope, "scope", "", "HELM", "Scope of this alias ")
+
+ _ = helmEnvAliasCreateCmd.MarkFlagRequired("key")
+ _ = helmEnvAliasCreateCmd.MarkFlagRequired("alias")
+ _ = helmEnvAliasCreateCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_env_create.go b/cmd/helm_env_create.go
new file mode 100644
index 00000000..32d169fc
--- /dev/null
+++ b/cmd/helm_env_create.go
@@ -0,0 +1,79 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmEnvCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create helm environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceVariable(client, projectId, envId, helm.Id, utils.HelmScope, utils.Key, utils.Value, utils.IsSecret)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ helmEnvCmd.AddCommand(helmEnvCreateCmd)
+ helmEnvCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmEnvCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmEnvCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmEnvCreateCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmEnvCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ helmEnvCreateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+ helmEnvCreateCmd.Flags().StringVarP(&utils.HelmScope, "scope", "", "HELM", "Scope of this env var ")
+ helmEnvCreateCmd.Flags().BoolVarP(&utils.IsSecret, "secret", "", false, "This environment variable is a secret")
+
+ _ = helmEnvCreateCmd.MarkFlagRequired("key")
+ _ = helmEnvCreateCmd.MarkFlagRequired("value")
+ _ = helmEnvCreateCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_env_delete.go b/cmd/helm_env_delete.go
new file mode 100644
index 00000000..6ba99dfa
--- /dev/null
+++ b/cmd/helm_env_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmEnvDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete helm environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, helm.Id, utils.HelmType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Variable %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ helmEnvCmd.AddCommand(helmEnvDeleteCmd)
+ helmEnvDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmEnvDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmEnvDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmEnvDeleteCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmEnvDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+
+ _ = helmEnvDeleteCmd.MarkFlagRequired("key")
+ _ = helmEnvDeleteCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_env_list.go b/cmd/helm_env_list.go
new file mode 100644
index 00000000..9ef8a58b
--- /dev/null
+++ b/cmd/helm_env_list.go
@@ -0,0 +1,106 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var helmEnvListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List helm environment variables",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVars, err := utils.ListServiceVariables(
+ client,
+ helm.Id,
+ utils.HelmType,
+ )
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVarLines := utils.NewEnvVarLines()
+ var variables []utils.EnvVarLineOutput
+
+ for _, envVar := range envVars {
+ s := utils.FromEnvironmentVariableToEnvVarLineOutput(envVar)
+ variables = append(variables, s)
+ envVarLines.Add(s)
+ }
+
+ if jsonFlag {
+ utils.Println(utils.GetEnvVarJsonOutput(variables, utils.SortKeys))
+ return
+ }
+
+ err = utils.PrintTable(envVarLines.Header(utils.PrettyPrint), envVarLines.Lines(utils.ShowValues, utils.PrettyPrint, utils.SortKeys))
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func init() {
+ helmEnvCmd.AddCommand(helmEnvListCmd)
+ helmEnvListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmEnvListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmEnvListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmEnvListCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmEnvListCmd.Flags().BoolVarP(&utils.ShowValues, "show-values", "", false, "Show env var values")
+ helmEnvListCmd.Flags().BoolVarP(&utils.PrettyPrint, "pretty-print", "", false, "Pretty print output")
+ helmEnvListCmd.Flags().BoolVarP(&utils.SortKeys, "sort", "", false, "Sort environment variables by key")
+ helmEnvListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = helmEnvListCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_env_override.go b/cmd/helm_env_override.go
new file mode 100644
index 00000000..4f0f0f86
--- /dev/null
+++ b/cmd/helm_env_override.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var helmEnvOverrideCmd = &cobra.Command{
+ Use: "override",
+ Short: "Manage helm environment variable and secret overrides",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ helmEnvCmd.AddCommand(helmEnvOverrideCmd)
+}
diff --git a/cmd/helm_env_override_create.go b/cmd/helm_env_override_create.go
new file mode 100644
index 00000000..a43c9ffb
--- /dev/null
+++ b/cmd/helm_env_override_create.go
@@ -0,0 +1,77 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmEnvOverrideCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Override helm environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceOverride(client, projectId, envId, helm.Id, utils.HelmType, utils.Key, utils.Value, utils.HelmScope)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("%s has been overidden", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ helmEnvOverrideCmd.AddCommand(helmEnvOverrideCreateCmd)
+ helmEnvOverrideCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmEnvOverrideCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmEnvOverrideCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmEnvOverrideCreateCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmEnvOverrideCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ helmEnvOverrideCreateCmd.Flags().StringVarP(&utils.Value, "value", "", "", "Environment variable or secret value")
+ helmEnvOverrideCreateCmd.Flags().StringVarP(&utils.HelmScope, "scope", "", "HELM", "Scope of this alias ")
+
+ _ = helmEnvOverrideCreateCmd.MarkFlagRequired("key")
+ _ = helmEnvOverrideCreateCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_env_update.go b/cmd/helm_env_update.go
new file mode 100644
index 00000000..e2b46d32
--- /dev/null
+++ b/cmd/helm_env_update.go
@@ -0,0 +1,77 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmEnvUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update helm environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceVariable(client, utils.Key, utils.Value, helm.Id, utils.HelmType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ helmEnvCmd.AddCommand(helmEnvUpdateCmd)
+ helmEnvUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmEnvUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmEnvUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmEnvUpdateCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmEnvUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ helmEnvUpdateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+
+ _ = helmEnvUpdateCmd.MarkFlagRequired("key")
+ _ = helmEnvUpdateCmd.MarkFlagRequired("value")
+ _ = helmEnvUpdateCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_external_secret.go b/cmd/helm_external_secret.go
new file mode 100644
index 00000000..7e64ec45
--- /dev/null
+++ b/cmd/helm_external_secret.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var helmExternalSecretCmd = &cobra.Command{
+ Use: "external-secret",
+ Short: "Manage helm external secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ helmCmd.AddCommand(helmExternalSecretCmd)
+}
diff --git a/cmd/helm_external_secret_create.go b/cmd/helm_external_secret_create.go
new file mode 100644
index 00000000..31292867
--- /dev/null
+++ b/cmd/helm_external_secret_create.go
@@ -0,0 +1,88 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmExternalSecretCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create helm external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceExternalSecret(client, projectId, envId, helm.Id, utils.HelmScope, utils.Key, utils.Reference, secretManagerAccessId, utils.MountPath)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ helmExternalSecretCmd.AddCommand(helmExternalSecretCreateCmd)
+ helmExternalSecretCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmExternalSecretCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmExternalSecretCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmExternalSecretCreateCmd.Flags().StringVarP(&helmName, "helm", "n", "", "Helm Name")
+ helmExternalSecretCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ helmExternalSecretCreateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "Reference to the secret in the secrets provider")
+ helmExternalSecretCreateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "Secret manager access name")
+ helmExternalSecretCreateCmd.Flags().StringVarP(&utils.HelmScope, "scope", "", "HELM", "Scope of this external secret ")
+ helmExternalSecretCreateCmd.Flags().StringVarP(&utils.MountPath, "mount-path", "", "", "Path where the secret will be mounted as a file")
+
+ _ = helmExternalSecretCreateCmd.MarkFlagRequired("key")
+ _ = helmExternalSecretCreateCmd.MarkFlagRequired("reference")
+ _ = helmExternalSecretCreateCmd.MarkFlagRequired("secret-manager-access-name")
+ _ = helmExternalSecretCreateCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_external_secret_delete.go b/cmd/helm_external_secret_delete.go
new file mode 100644
index 00000000..f2c65a22
--- /dev/null
+++ b/cmd/helm_external_secret_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmExternalSecretDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete helm external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, helm.Id, utils.HelmType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ helmExternalSecretCmd.AddCommand(helmExternalSecretDeleteCmd)
+ helmExternalSecretDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmExternalSecretDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmExternalSecretDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmExternalSecretDeleteCmd.Flags().StringVarP(&helmName, "helm", "n", "", "Helm Name")
+ helmExternalSecretDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+
+ _ = helmExternalSecretDeleteCmd.MarkFlagRequired("key")
+ _ = helmExternalSecretDeleteCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_external_secret_update.go b/cmd/helm_external_secret_update.go
new file mode 100644
index 00000000..0d887783
--- /dev/null
+++ b/cmd/helm_external_secret_update.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmExternalSecretUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update helm external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceExternalSecret(client, utils.Key, utils.Reference, secretManagerAccessId, helm.Id, utils.HelmType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ helmExternalSecretCmd.AddCommand(helmExternalSecretUpdateCmd)
+ helmExternalSecretUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmExternalSecretUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmExternalSecretUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmExternalSecretUpdateCmd.Flags().StringVarP(&helmName, "helm", "n", "", "Helm Name")
+ helmExternalSecretUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ helmExternalSecretUpdateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "New reference to the secret in the secrets provider")
+ helmExternalSecretUpdateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "New secret manager access name")
+
+ _ = helmExternalSecretUpdateCmd.MarkFlagRequired("key")
+ _ = helmExternalSecretUpdateCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_list.go b/cmd/helm_list.go
new file mode 100644
index 00000000..b1ffb0cf
--- /dev/null
+++ b/cmd/helm_list.go
@@ -0,0 +1,102 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var helmListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List helms",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(getHelmJsonOutput(helms.GetResults(), statuses))
+ return
+ }
+
+ var data [][]string
+
+ for _, helm := range helms.GetResults() {
+ data = append(data, []string{helm.Id, helm.Name, "Helm",
+ utils.FindStatusTextWithColor(statuses.GetHelms(), helm.Id), helm.UpdatedAt.String()})
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name", "Type", "Status", "Last Update"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getHelmJsonOutput(helms []qovery.HelmResponse, statuses *qovery.EnvironmentStatuses) string {
+ var results []interface{}
+
+ for _, helm := range helms {
+ results = append(results, map[string]interface{}{
+ "id": helm.Id,
+ "name": helm.Name,
+ "type": "Helm",
+ "status": utils.FindStatus(statuses.GetHelms(), helm.Id),
+ "last_update": helm.UpdatedAt.String(),
+ })
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ helmCmd.AddCommand(helmListCmd)
+ helmListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/helm_redeploy.go b/cmd/helm_redeploy.go
new file mode 100644
index 00000000..6349f34a
--- /dev/null
+++ b/cmd/helm_redeploy.go
@@ -0,0 +1,44 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmRedeployCmd = &cobra.Command{
+ Use: "redeploy",
+ Short: "Redeploy a helm",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateHelmArguments(helmName, helmNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ helmList := buildHelmListFromHelmNames(client, envId, helmName, helmNames)
+
+ _, _, err := client.HelmActionsAPI.
+ DeployHelm(context.Background(), helmList[0].Id).
+ HelmDeployRequest(qovery.HelmDeployRequest{}).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to redeploy helm(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", helmName, helmNames)))
+ WatchHelmDeployment(client, envId, helmList, watchFlag, qovery.STATEENUM_RESTARTED)
+ },
+}
+
+func init() {
+ helmCmd.AddCommand(helmRedeployCmd)
+ helmRedeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmRedeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmRedeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmRedeployCmd.Flags().StringVarP(&helmName, "helm", "n", "", "Helm Name")
+ helmRedeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch helm status until it's ready or an error occurs")
+
+ _ = helmRedeployCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/helm_stop.go b/cmd/helm_stop.go
new file mode 100644
index 00000000..e2daf24d
--- /dev/null
+++ b/cmd/helm_stop.go
@@ -0,0 +1,99 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+ "os"
+ "strings"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop a helm",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateHelmArguments(helmName, helmNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ helmList := buildHelmListFromHelmNames(client, envId, helmName, helmNames)
+ _, err := client.EnvironmentActionsAPI.
+ StopSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ HelmIds: utils.Map(helmList, func(helm *qovery.HelmResponse) string {
+ return helm.Id
+ }),
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to stop helm(s) %s has been queued...", pterm.FgBlue.Sprintf("%s%s", helmName, helmNames)))
+ WatchHelmDeployment(client, envId, helmList, watchFlag, qovery.STATEENUM_STOPPED)
+ },
+}
+
+func buildHelmListFromHelmNames(
+ client *qovery.APIClient,
+ environmentId string,
+ helmName string,
+ helmNames string,
+) []*qovery.HelmResponse {
+ var helmList []*qovery.HelmResponse
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ if helmName != "" {
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ helmList = append(helmList, helm)
+ }
+ if helmNames != "" {
+ for _, helmName := range strings.Split(helmNames, ",") {
+ trimmedHelmName := strings.TrimSpace(helmName)
+ helm := utils.FindByHelmName(helms.GetResults(), trimmedHelmName)
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ helmList = append(helmList, helm)
+ }
+ }
+
+ return helmList
+}
+
+func validateHelmArguments(helmName string, helmNames string) {
+ if helmName == "" && helmNames == "" {
+ utils.PrintlnError(fmt.Errorf("use either --helm \"\" or --helms \", \" but not both at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if helmName != "" && helmNames != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --helm and --helms at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
+
+func init() {
+ helmCmd.AddCommand(helmStopCmd)
+ helmStopCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmStopCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmStopCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmStopCmd.Flags().StringVarP(&helmName, "helm", "n", "", "Helm Name")
+ helmStopCmd.Flags().StringVarP(&helmNames, "helms", "", "", "Helm Names (comma separated) (ex: --helms \"helm1,helm2\")")
+ helmStopCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch helm status until it's ready or an error occurs")
+}
diff --git a/cmd/helm_update.go b/cmd/helm_update.go
new file mode 100644
index 00000000..38ef9b13
--- /dev/null
+++ b/cmd/helm_update.go
@@ -0,0 +1,232 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/pkg/errors"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update a helm",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ var ports []qovery.HelmPortRequestPortsInner
+ for _, p := range helm.Ports {
+ if p.HelmPortResponseWithServiceName != nil {
+ portWithServiceName := p.HelmPortResponseWithServiceName
+ ports = append(ports, qovery.HelmPortRequestPortsInner{
+ Name: portWithServiceName.Name,
+ InternalPort: portWithServiceName.InternalPort,
+ ExternalPort: portWithServiceName.ExternalPort,
+ ServiceName: &portWithServiceName.ServiceName,
+ Namespace: portWithServiceName.Namespace,
+ Protocol: &portWithServiceName.Protocol,
+ })
+ }
+ }
+
+ source, err := GetHelmSource(helm, chartName, chartVersion, charGitCommitBranch)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ valuesOverride, err := GetHelmValuesOverride(helm, valuesOverrideCommitBranch)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ autoPreview := qovery.NullableBool{}
+ autoPreview.Set(&helm.AutoPreview)
+ req := qovery.HelmRequest{
+ Ports: ports,
+ Name: helm.Name,
+ Description: helm.Description,
+ TimeoutSec: helm.TimeoutSec,
+ AutoPreview: autoPreview,
+ AutoDeploy: helm.AutoDeploy,
+ Source: *source,
+ Arguments: helm.Arguments,
+ AllowClusterWideResources: &helm.AllowClusterWideResources,
+ ValuesOverride: *valuesOverride,
+ }
+
+ _, res, err := client.HelmMainCallsAPI.EditHelm(context.Background(), helm.Id).HelmRequest(req).Execute()
+
+ if err != nil {
+ // print http body error message
+ if res.StatusCode != 200 {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ }
+
+ utils.PrintlnError(err)
+
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("helm %s updated!", pterm.FgBlue.Sprintf("%s", helmName)))
+ },
+}
+
+func GetHelmSource(helm *qovery.HelmResponse, chartName string, chartVersion string, charGitCommitBranch string) (*qovery.HelmRequestAllOfSource, error) {
+
+ if git := utils.GetGitSource(helm); git != nil {
+ updatedBranch := git.GitRepository.Branch
+ if charGitCommitBranch != "" {
+ updatedBranch = &charGitCommitBranch
+ }
+
+ return &qovery.HelmRequestAllOfSource{
+ HelmRequestAllOfSourceOneOf: &qovery.HelmRequestAllOfSourceOneOf{
+ GitRepository: &qovery.HelmGitRepositoryRequest{
+ Url: git.GitRepository.Url,
+ Branch: updatedBranch,
+ RootPath: git.GitRepository.RootPath,
+ GitTokenId: git.GitRepository.GitTokenId,
+ },
+ },
+ }, nil
+
+ } else if repository := utils.GetHelmRepository(helm); repository != nil {
+ updatedChartName := &repository.ChartName
+ if chartName != "" {
+ updatedChartName = &chartName
+ }
+
+ updatedChartVersion := &repository.ChartVersion
+ if chartVersion != "" {
+ updatedChartVersion = &chartVersion
+ }
+
+ repositoryId := qovery.NullableString{}
+ repositoryId.Set(&repository.Repository.Id)
+
+ return &qovery.HelmRequestAllOfSource{
+ HelmRequestAllOfSourceOneOf: nil,
+ HelmRequestAllOfSourceOneOf1: &qovery.HelmRequestAllOfSourceOneOf1{
+ HelmRepository: &qovery.HelmRequestAllOfSourceOneOf1HelmRepository{
+ Repository: repositoryId,
+ ChartName: updatedChartName,
+ ChartVersion: updatedChartVersion,
+ },
+ },
+ }, nil
+ }
+
+ return nil, fmt.Errorf("invalid Helm source")
+}
+
+func GetHelmValuesOverride(helm *qovery.HelmResponse, valuesOverrideCommitBranch string) (*qovery.HelmRequestAllOfValuesOverride, error) {
+ helmRequest := qovery.HelmRequestAllOfValuesOverride{}
+ helmRequest.SetSet(helm.ValuesOverride.Set)
+ helmRequest.SetSetString(helm.ValuesOverride.SetString)
+ helmRequest.SetSetJson(helm.ValuesOverride.SetJson)
+ helmRequest.SetSetJson(helm.ValuesOverride.SetJson)
+
+ if helm.ValuesOverride.File.Get() != nil && helm.ValuesOverride.File.Get().Git.Get() != nil {
+ git := helm.ValuesOverride.File.Get().Git.Get()
+
+ updatedBranch := git.GitRepository.Branch
+ if valuesOverrideCommitBranch != "" {
+ updatedBranch = &valuesOverrideCommitBranch
+ }
+
+ updatedFile := qovery.HelmRequestAllOfValuesOverrideFile{}
+ updatedFile.SetGit(qovery.HelmRequestAllOfValuesOverrideFileGit{
+ Paths: git.Paths,
+ GitRepository: qovery.ApplicationGitRepositoryRequest{
+ Url: git.GitRepository.Url,
+ Branch: updatedBranch,
+ GitTokenId: git.GitRepository.GitTokenId,
+ RootPath: git.GitRepository.RootPath,
+ Provider: git.GitRepository.Provider,
+ },
+ })
+ updatedFile.SetRawNil()
+ helmRequest.SetFile(updatedFile)
+
+ return &helmRequest, nil
+ } else if helm.ValuesOverride.File.Get() != nil && helm.ValuesOverride.File.Get().Raw.Get() != nil {
+ raw := helm.ValuesOverride.File.Get().Raw.Get()
+
+ var values = make([]qovery.HelmRequestAllOfValuesOverrideFileRawValues, len(raw.Values))
+ for ix, value := range raw.Values {
+ values[ix] = qovery.HelmRequestAllOfValuesOverrideFileRawValues{
+ Name: &value.Name,
+ Content: &value.Content,
+ }
+ }
+
+ updatedFile := qovery.HelmRequestAllOfValuesOverrideFile{}
+ updatedFile.SetRaw(qovery.HelmRequestAllOfValuesOverrideFileRaw{
+ Values: values,
+ })
+ helmRequest.SetFile(updatedFile)
+
+ return &helmRequest, nil
+ }
+
+ return nil, fmt.Errorf("invalid Helm values orerride")
+}
+
+func init() {
+ helmCmd.AddCommand(helmUpdateCmd)
+ helmUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmUpdateCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmUpdateCmd.Flags().StringVarP(&chartName, "chart_name", "", "", "helm chart name")
+ helmUpdateCmd.Flags().StringVarP(&chartVersion, "chart_version", "", "", "helm chart version")
+ helmUpdateCmd.Flags().StringVarP(&charGitCommitBranch, "chart_git_commit_branch", "", "", "helm chart version")
+ helmUpdateCmd.Flags().StringVarP(&valuesOverrideCommitBranch, "values_override_git_commit_branch", "", "", "helm chart version")
+
+ _ = helmUpdateCmd.MarkFlagRequired("helm")
+}
diff --git a/cmd/hem_domain_delete.go b/cmd/hem_domain_delete.go
new file mode 100644
index 00000000..77c182af
--- /dev/null
+++ b/cmd/hem_domain_delete.go
@@ -0,0 +1,90 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var helmDomainDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete helm custom domain",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ utils.PrintlnError(fmt.Errorf("helm %s not found", helmName))
+ utils.PrintlnInfo("You can list all helms with: qovery helm list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomains, _, err := client.HelmCustomDomainAPI.ListHelmCustomDomain(context.Background(), helm.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ customDomain := utils.FindByCustomDomainName(customDomains.GetResults(), helmCustomDomain)
+ if customDomain == nil {
+ utils.PrintlnError(fmt.Errorf("custom domain %s does not exist", helmCustomDomain))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ _, err = client.HelmCustomDomainAPI.DeleteHelmCustomDomain(context.Background(), helm.Id, customDomain.Id).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Custom domain %s has been deleted", pterm.FgBlue.Sprintf("%s", helmCustomDomain)))
+ },
+}
+
+func init() {
+ helmDomainCmd.AddCommand(helmDomainDeleteCmd)
+ helmDomainDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ helmDomainDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ helmDomainDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ helmDomainDeleteCmd.Flags().StringVarP(&helmName, "helm", "n", "", "helm Name")
+ helmDomainDeleteCmd.Flags().StringVarP(&helmCustomDomain, "domain", "", "", "Custom Domain ")
+
+ _ = helmDomainDeleteCmd.MarkFlagRequired("helm")
+ _ = helmDomainDeleteCmd.MarkFlagRequired("domain")
+}
diff --git a/cmd/lifecycle.go b/cmd/lifecycle.go
new file mode 100644
index 00000000..9ad24ce5
--- /dev/null
+++ b/cmd/lifecycle.go
@@ -0,0 +1,53 @@
+package cmd
+
+import (
+ "context"
+ "os"
+
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleName string
+var lifecycleNames string
+var lifecycleCommitId string
+var lifecycleTag string
+var lifecycleImageName string
+var lifecycleBranch string
+var targetLifecycleName string
+
+var lifecycleCmd = &cobra.Command{
+ Use: "lifecycle",
+ Short: "Manage lifecycle jobs",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(lifecycleCmd)
+}
+
+func ListLifecycleJobs(envId string, client *qovery.APIClient) ([]qovery.JobResponse, error) {
+ jobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ return nil, err
+ }
+
+ lifecycleJobs := make([]qovery.JobResponse, 0)
+ for _, job := range jobs.GetResults() {
+ if job.LifecycleJobResponse != nil {
+ lifecycleJobs = append(lifecycleJobs, job)
+ }
+ }
+
+ return lifecycleJobs, nil
+}
diff --git a/cmd/lifecycle_cancel.go b/cmd/lifecycle_cancel.go
new file mode 100644
index 00000000..5c257fd0
--- /dev/null
+++ b/cmd/lifecycle_cancel.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleCancelCmd = &cobra.Command{
+ Use: "cancel",
+ Short: "Cancel a lifecycle deployment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ msg, err := utils.CancelServiceDeployment(client, envId, lifecycle.LifecycleJobResponse.Id, utils.JobType, watchFlag)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if msg != "" {
+ utils.PrintlnInfo(msg)
+ return
+ }
+
+ utils.Println(fmt.Sprintf("Lifecycle %s deployment cancelled!", pterm.FgBlue.Sprintf("%s", lifecycleName)))
+ },
+}
+
+func init() {
+ lifecycleCmd.AddCommand(lifecycleCancelCmd)
+ lifecycleCancelCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleCancelCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleCancelCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleCancelCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleCancelCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch cancel until it's done or an error occurs")
+
+ _ = lifecycleCancelCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_clone.go b/cmd/lifecycle_clone.go
new file mode 100644
index 00000000..445422c5
--- /dev/null
+++ b/cmd/lifecycle_clone.go
@@ -0,0 +1,116 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/go-errors/errors"
+ "io"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleCloneCmd = &cobra.Command{
+ Use: "clone",
+ Short: "Clone a lifecycle job",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ job, err := getJobContextResource(client, lifecycleName, envId)
+
+ if err != nil || job == nil || job.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all jobs with: qovery job list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ targetProjectId := projectId // use same project as the source project
+ if targetProjectName != "" {
+
+ targetProjectId, err = getProjectContextResourceId(client, targetProjectName, organizationId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ targetEnvironmentId := envId // use same env as the source env
+ if targetEnvironmentName != "" {
+
+ targetEnvironmentId, err = getEnvironmentContextResourceId(client, targetEnvironmentName, targetProjectId)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ }
+
+ if targetLifecycleName == "" {
+ // use same job name as the source job
+ targetLifecycleName = job.LifecycleJobResponse.Name
+ }
+
+ req := qovery.CloneServiceRequest{
+ Name: targetLifecycleName,
+ EnvironmentId: targetEnvironmentId,
+ }
+
+ clonedService, res, err := client.JobsAPI.CloneJob(context.Background(), job.LifecycleJobResponse.Id).CloneServiceRequest(req).Execute()
+
+ if err != nil {
+ // print http body error message
+ if res.StatusCode != 200 {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ }
+
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ name := ""
+ if clonedService != nil {
+ name = clonedService.LifecycleJobResponse.Name
+ }
+
+ utils.Println(fmt.Sprintf("Job %s cloned!", pterm.FgBlue.Sprintf("%s", name)))
+ },
+}
+
+func init() {
+ lifecycleCmd.AddCommand(lifecycleCloneCmd)
+ lifecycleCloneCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleCloneCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleCloneCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleCloneCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleCloneCmd.Flags().StringVarP(&targetProjectName, "target-project", "", "", "Target Project Name")
+ lifecycleCloneCmd.Flags().StringVarP(&targetEnvironmentName, "target-environment", "", "", "Target Environment Name")
+ lifecycleCloneCmd.Flags().StringVarP(&targetLifecycleName, "target-lifecycle-name", "", "", "Target Lifecycle Name")
+
+ _ = lifecycleCloneCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_delete.go b/cmd/lifecycle_delete.go
new file mode 100644
index 00000000..15ad7acb
--- /dev/null
+++ b/cmd/lifecycle_delete.go
@@ -0,0 +1,47 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete a lifecycle job",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateLifecycleArguments(lifecycleName, lifecycleNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ lifecycleList := buildLifecycleListFromLifecycleNames(client, envId, lifecycleName, lifecycleNames)
+
+ _, err := client.EnvironmentActionsAPI.
+ DeleteSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ JobIds: utils.Map(lifecycleList, func(lifecycle *qovery.JobResponse) string {
+ return utils.GetJobId(lifecycle)
+ }),
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to delete lifecycle job(s) %s has been queued...", pterm.FgBlue.Sprintf("%s%s", lifecycleName, lifecycleNames)))
+ WatchJobDeployment(client, envId, lifecycleList, watchFlag, qovery.STATEENUM_DELETED)
+ },
+}
+
+func init() {
+ lifecycleCmd.AddCommand(lifecycleDeleteCmd)
+ lifecycleDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleDeleteCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Job Name")
+ lifecycleDeleteCmd.Flags().StringVarP(&lifecycleNames, "lifecycles", "", "", "Lifecycle Job Names (comma separated) (ex: --lifecycles \"lifecycle1,lifecycle2\")")
+ lifecycleDeleteCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch lifecycle job status until it's ready or an error occurs")
+}
diff --git a/cmd/lifecycle_deploy.go b/cmd/lifecycle_deploy.go
new file mode 100644
index 00000000..418aa321
--- /dev/null
+++ b/cmd/lifecycle_deploy.go
@@ -0,0 +1,48 @@
+package cmd
+
+import (
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy a lifecycle job",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateLifecycleArguments(lifecycleName, lifecycleNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ if lifecycleTag != "" && lifecycleCommitId != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --tag and --commit-id at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecyleList := buildLifecycleListFromLifecycleNames(client, envId, lifecycleName, lifecycleNames)
+ err := utils.DeployJobs(client, envId, lifecyleList, lifecycleCommitId, lifecycleTag)
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to deploy lifecycle job(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", lifecycleName, lifecycleNames)))
+ WatchJobDeployment(client, envId, lifecyleList, watchFlag, qovery.STATEENUM_DEPLOYED)
+ },
+}
+
+func init() {
+ lifecycleCmd.AddCommand(lifecycleDeployCmd)
+ lifecycleDeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleDeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleDeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleDeployCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Job Name")
+ lifecycleDeployCmd.Flags().StringVarP(&lifecycleNames, "lifecycles", "", "", "Lifecycle Job Names")
+ lifecycleDeployCmd.Flags().StringVarP(&lifecycleCommitId, "commit-id", "c", "", "Lifecycle Commit ID")
+ lifecycleDeployCmd.Flags().StringVarP(&lifecycleTag, "tag", "t", "", "Lifecycle Tag")
+ lifecycleDeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch lifecycle status until it's ready or an error occurs")
+}
diff --git a/cmd/lifecycle_env.go b/cmd/lifecycle_env.go
new file mode 100644
index 00000000..bef23a27
--- /dev/null
+++ b/cmd/lifecycle_env.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var lifecycleEnvCmd = &cobra.Command{
+ Use: "env",
+ Short: "Manage lifecycle environment variables and secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ lifecycleCmd.AddCommand(lifecycleEnvCmd)
+}
diff --git a/cmd/lifecycle_env_alias.go b/cmd/lifecycle_env_alias.go
new file mode 100644
index 00000000..83fc7b92
--- /dev/null
+++ b/cmd/lifecycle_env_alias.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var lifecycleEnvAliasCmd = &cobra.Command{
+ Use: "alias",
+ Short: "Manage lifecycle environment variable and secret aliases",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ lifecycleEnvCmd.AddCommand(lifecycleEnvAliasCmd)
+}
diff --git a/cmd/lifecycle_env_alias_create.go b/cmd/lifecycle_env_alias_create.go
new file mode 100644
index 00000000..5c480270
--- /dev/null
+++ b/cmd/lifecycle_env_alias_create.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleEnvAliasCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create lifecycle environment variable or secret alias",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceAlias(client, projectId, envId, lifecycle.LifecycleJobResponse.Id, utils.JobType, utils.Key, utils.Alias, utils.JobScope)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Alias %s has been created", pterm.FgBlue.Sprintf("%s", utils.Alias)))
+ },
+}
+
+func init() {
+ lifecycleEnvAliasCmd.AddCommand(lifecycleEnvAliasCreateCmd)
+ lifecycleEnvAliasCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleEnvAliasCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleEnvAliasCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleEnvAliasCreateCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleEnvAliasCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ lifecycleEnvAliasCreateCmd.Flags().StringVarP(&utils.Alias, "alias", "", "", "Environment variable or secret alias")
+ lifecycleEnvAliasCreateCmd.Flags().StringVarP(&utils.JobScope, "scope", "", "JOB", "Scope of this alias ")
+
+ _ = lifecycleEnvAliasCreateCmd.MarkFlagRequired("key")
+ _ = lifecycleEnvAliasCreateCmd.MarkFlagRequired("alias")
+ _ = lifecycleEnvAliasCreateCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_env_create.go b/cmd/lifecycle_env_create.go
new file mode 100644
index 00000000..225dc033
--- /dev/null
+++ b/cmd/lifecycle_env_create.go
@@ -0,0 +1,79 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleEnvCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create lifecycle environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceVariable(client, projectId, envId, lifecycle.LifecycleJobResponse.Id, utils.JobScope, utils.Key, utils.Value, utils.IsSecret)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ lifecycleEnvCmd.AddCommand(lifecycleEnvCreateCmd)
+ lifecycleEnvCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleEnvCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleEnvCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleEnvCreateCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleEnvCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ lifecycleEnvCreateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+ lifecycleEnvCreateCmd.Flags().StringVarP(&utils.JobScope, "scope", "", "JOB", "Scope of this env var ")
+ lifecycleEnvCreateCmd.Flags().BoolVarP(&utils.IsSecret, "secret", "", false, "This environment variable is a secret")
+
+ _ = lifecycleEnvCreateCmd.MarkFlagRequired("key")
+ _ = lifecycleEnvCreateCmd.MarkFlagRequired("value")
+ _ = lifecycleEnvCreateCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_env_delete.go b/cmd/lifecycle_env_delete.go
new file mode 100644
index 00000000..cf2b71a7
--- /dev/null
+++ b/cmd/lifecycle_env_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleEnvDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete lifecycle environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, lifecycle.LifecycleJobResponse.Id, utils.JobType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Variable %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ lifecycleEnvCmd.AddCommand(lifecycleEnvDeleteCmd)
+ lifecycleEnvDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleEnvDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleEnvDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleEnvDeleteCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleEnvDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+
+ _ = lifecycleEnvDeleteCmd.MarkFlagRequired("key")
+ _ = lifecycleEnvDeleteCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_env_list.go b/cmd/lifecycle_env_list.go
new file mode 100644
index 00000000..cbf3181c
--- /dev/null
+++ b/cmd/lifecycle_env_list.go
@@ -0,0 +1,101 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleEnvListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List lifecycle environment variables",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVars, err := utils.ListServiceVariables(
+ client,
+ lifecycle.LifecycleJobResponse.Id,
+ utils.JobType,
+ )
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envVarLines := utils.NewEnvVarLines()
+ var variables []utils.EnvVarLineOutput
+
+ for _, envVar := range envVars {
+ s := utils.FromEnvironmentVariableToEnvVarLineOutput(envVar)
+ variables = append(variables, s)
+ envVarLines.Add(s)
+ }
+
+ if jsonFlag {
+ utils.Println(utils.GetEnvVarJsonOutput(variables, utils.SortKeys))
+ return
+ }
+
+ err = utils.PrintTable(envVarLines.Header(utils.PrettyPrint), envVarLines.Lines(utils.ShowValues, utils.PrettyPrint, utils.SortKeys))
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func init() {
+ lifecycleEnvCmd.AddCommand(lifecycleEnvListCmd)
+ lifecycleEnvListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleEnvListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleEnvListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleEnvListCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleEnvListCmd.Flags().BoolVarP(&utils.ShowValues, "show-values", "", false, "Show env var values")
+ lifecycleEnvListCmd.Flags().BoolVarP(&utils.PrettyPrint, "pretty-print", "", false, "Pretty print output")
+ lifecycleEnvListCmd.Flags().BoolVarP(&utils.SortKeys, "sort", "", false, "Sort environment variables by key")
+ lifecycleEnvListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = lifecycleEnvListCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_env_override.go b/cmd/lifecycle_env_override.go
new file mode 100644
index 00000000..66867826
--- /dev/null
+++ b/cmd/lifecycle_env_override.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var lifecycleEnvOverrideCmd = &cobra.Command{
+ Use: "override",
+ Short: "Manage lifecycle environment variable and secret overrides",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ lifecycleEnvCmd.AddCommand(lifecycleEnvOverrideCmd)
+}
diff --git a/cmd/lifecycle_env_override_create.go b/cmd/lifecycle_env_override_create.go
new file mode 100644
index 00000000..263ab5e1
--- /dev/null
+++ b/cmd/lifecycle_env_override_create.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleEnvOverrideCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Override lifecycle environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceOverride(client, projectId, envId, lifecycle.LifecycleJobResponse.Id, utils.JobType, utils.Key, utils.Value, utils.JobScope)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("%s has been overidden", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ lifecycleEnvOverrideCmd.AddCommand(lifecycleEnvOverrideCreateCmd)
+ lifecycleEnvOverrideCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleEnvOverrideCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleEnvOverrideCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleEnvOverrideCreateCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleEnvOverrideCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ lifecycleEnvOverrideCreateCmd.Flags().StringVarP(&utils.Value, "value", "", "", "Environment variable or secret value")
+ lifecycleEnvOverrideCreateCmd.Flags().StringVarP(&utils.JobScope, "scope", "", "JOB", "Scope of this alias ")
+
+ _ = lifecycleEnvOverrideCreateCmd.MarkFlagRequired("key")
+ _ = lifecycleEnvOverrideCreateCmd.MarkFlagRequired("lifecycle")
+ _ = lifecycleEnvOverrideCreateCmd.MarkFlagRequired("value")
+}
diff --git a/cmd/lifecycle_env_update.go b/cmd/lifecycle_env_update.go
new file mode 100644
index 00000000..3b61b3ec
--- /dev/null
+++ b/cmd/lifecycle_env_update.go
@@ -0,0 +1,76 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleEnvUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update lifecycle environment variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceVariable(client, utils.Key, utils.Value, lifecycle.LifecycleJobResponse.Id, utils.JobType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Environment variable %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ lifecycleEnvCmd.AddCommand(lifecycleEnvUpdateCmd)
+ lifecycleEnvUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleEnvUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleEnvUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleEnvUpdateCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleEnvUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Environment variable or secret key")
+ lifecycleEnvUpdateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Environment variable or secret value")
+ _ = lifecycleEnvUpdateCmd.MarkFlagRequired("key")
+ _ = lifecycleEnvUpdateCmd.MarkFlagRequired("value")
+ _ = lifecycleEnvUpdateCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_external_secret.go b/cmd/lifecycle_external_secret.go
new file mode 100644
index 00000000..0a90598a
--- /dev/null
+++ b/cmd/lifecycle_external_secret.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var lifecycleExternalSecretCmd = &cobra.Command{
+ Use: "external-secret",
+ Short: "Manage lifecycle external secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ lifecycleCmd.AddCommand(lifecycleExternalSecretCmd)
+}
diff --git a/cmd/lifecycle_external_secret_create.go b/cmd/lifecycle_external_secret_create.go
new file mode 100644
index 00000000..fdb5328f
--- /dev/null
+++ b/cmd/lifecycle_external_secret_create.go
@@ -0,0 +1,88 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleExternalSecretCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create lifecycle external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceExternalSecret(client, projectId, envId, lifecycle.LifecycleJobResponse.Id, utils.JobScope, utils.Key, utils.Reference, secretManagerAccessId, utils.MountPath)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ lifecycleExternalSecretCmd.AddCommand(lifecycleExternalSecretCreateCmd)
+ lifecycleExternalSecretCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleExternalSecretCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleExternalSecretCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleExternalSecretCreateCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleExternalSecretCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ lifecycleExternalSecretCreateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "Reference to the secret in the secrets provider")
+ lifecycleExternalSecretCreateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "Secret manager access name")
+ lifecycleExternalSecretCreateCmd.Flags().StringVarP(&utils.JobScope, "scope", "", "JOB", "Scope of this external secret ")
+ lifecycleExternalSecretCreateCmd.Flags().StringVarP(&utils.MountPath, "mount-path", "", "", "Path where the secret will be mounted as a file")
+
+ _ = lifecycleExternalSecretCreateCmd.MarkFlagRequired("key")
+ _ = lifecycleExternalSecretCreateCmd.MarkFlagRequired("reference")
+ _ = lifecycleExternalSecretCreateCmd.MarkFlagRequired("secret-manager-access-name")
+ _ = lifecycleExternalSecretCreateCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_external_secret_delete.go b/cmd/lifecycle_external_secret_delete.go
new file mode 100644
index 00000000..ee3c0acb
--- /dev/null
+++ b/cmd/lifecycle_external_secret_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleExternalSecretDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete lifecycle external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, lifecycle.LifecycleJobResponse.Id, utils.JobType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ lifecycleExternalSecretCmd.AddCommand(lifecycleExternalSecretDeleteCmd)
+ lifecycleExternalSecretDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleExternalSecretDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleExternalSecretDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleExternalSecretDeleteCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleExternalSecretDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+
+ _ = lifecycleExternalSecretDeleteCmd.MarkFlagRequired("key")
+ _ = lifecycleExternalSecretDeleteCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_external_secret_update.go b/cmd/lifecycle_external_secret_update.go
new file mode 100644
index 00000000..fc5670f9
--- /dev/null
+++ b/cmd/lifecycle_external_secret_update.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleExternalSecretUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update lifecycle external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceExternalSecret(client, utils.Key, utils.Reference, secretManagerAccessId, lifecycle.LifecycleJobResponse.Id, utils.JobType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ lifecycleExternalSecretCmd.AddCommand(lifecycleExternalSecretUpdateCmd)
+ lifecycleExternalSecretUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleExternalSecretUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleExternalSecretUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleExternalSecretUpdateCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleExternalSecretUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ lifecycleExternalSecretUpdateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "New reference to the secret in the secrets provider")
+ lifecycleExternalSecretUpdateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "New secret manager access name")
+
+ _ = lifecycleExternalSecretUpdateCmd.MarkFlagRequired("key")
+ _ = lifecycleExternalSecretUpdateCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_list.go b/cmd/lifecycle_list.go
new file mode 100644
index 00000000..4fba728d
--- /dev/null
+++ b/cmd/lifecycle_list.go
@@ -0,0 +1,109 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "github.com/qovery/qovery-client-go"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var lifecycleListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List lifecycle jobs",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, err := ListLifecycleJobs(envId, client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ fmt.Print(getLifecycleJsonOutput(statuses.GetJobs(), lifecycles))
+ return
+ }
+
+ var data [][]string
+
+ for _, lifecycle := range lifecycles {
+ if lifecycle.LifecycleJobResponse != nil {
+ data = append(data, []string{lifecycle.LifecycleJobResponse.Id, lifecycle.LifecycleJobResponse.Name, "Lifecycle",
+ utils.FindStatusTextWithColor(statuses.GetJobs(), lifecycle.LifecycleJobResponse.Id), lifecycle.LifecycleJobResponse.UpdatedAt.String()})
+ }
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name", "Type", "Status", "Last Update"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getLifecycleJsonOutput(statuses []qovery.Status, lifecycles []qovery.JobResponse) string {
+ var results []interface{}
+
+ for _, lifecycle := range lifecycles {
+ if lifecycle.LifecycleJobResponse != nil {
+ results = append(results, map[string]interface{}{
+ "id": lifecycle.LifecycleJobResponse.Id,
+ "name": lifecycle.LifecycleJobResponse.Name,
+ "type": "Lifecycle",
+ "status": utils.FindStatus(statuses, lifecycle.LifecycleJobResponse.Id),
+ "updated_at": utils.ToIso8601(lifecycle.LifecycleJobResponse.UpdatedAt),
+ })
+ }
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ lifecycleCmd.AddCommand(lifecycleListCmd)
+ lifecycleListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/lifecycle_redeploy.go b/cmd/lifecycle_redeploy.go
new file mode 100644
index 00000000..d65ef46b
--- /dev/null
+++ b/cmd/lifecycle_redeploy.go
@@ -0,0 +1,43 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleRedeployCmd = &cobra.Command{
+ Use: "redeploy",
+ Short: "Redeploy a lifecycle job",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateLifecycleArguments(lifecycleName, lifecycleNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ lifecycleList := buildLifecycleListFromLifecycleNames(client, envId, lifecycleName, lifecycleNames)
+ _, _, err := client.JobActionsAPI.
+ DeployJob(context.Background(), utils.GetJobId(lifecycleList[0])).
+ JobDeployRequest(qovery.JobDeployRequest{}).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to redeploy lifecycle job(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", lifecycleName, lifecycleNames)))
+ WatchJobDeployment(client, envId, lifecycleList, watchFlag, qovery.STATEENUM_RESTARTED)
+ },
+}
+
+func init() {
+ lifecycleCmd.AddCommand(lifecycleRedeployCmd)
+ lifecycleRedeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleRedeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleRedeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleRedeployCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleRedeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch lifecycle status until it's ready or an error occurs")
+
+ _ = lifecycleRedeployCmd.MarkFlagRequired("lifecycle")
+}
diff --git a/cmd/lifecycle_stop.go b/cmd/lifecycle_stop.go
new file mode 100644
index 00000000..65812847
--- /dev/null
+++ b/cmd/lifecycle_stop.go
@@ -0,0 +1,100 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop a lifecycle job",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateLifecycleArguments(lifecycleName, lifecycleNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ lifecycleList := buildLifecycleListFromLifecycleNames(client, envId, lifecycleName, lifecycleNames)
+ _, err := client.EnvironmentActionsAPI.
+ StopSelectedServices(context.Background(), envId).
+ EnvironmentServiceIdsAllRequest(qovery.EnvironmentServiceIdsAllRequest{
+ JobIds: utils.Map(lifecycleList, func(lifecycle *qovery.JobResponse) string {
+ return utils.GetJobId(lifecycle)
+ }),
+ }).
+ Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Request to stop lifecycle job(s) %s has been queued...", pterm.FgBlue.Sprintf("%s%s", lifecycleName, lifecycleNames)))
+ WatchJobDeployment(client, envId, lifecycleList, watchFlag, qovery.STATEENUM_STOPPED)
+ },
+}
+
+func buildLifecycleListFromLifecycleNames(
+ client *qovery.APIClient,
+ environmentId string,
+ lifecycleName string,
+ lifecycleNames string,
+) []*qovery.JobResponse {
+ var lifecycleList []*qovery.JobResponse
+ lifecycles, _, err := client.JobsAPI.ListJobs(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ if lifecycleName != "" {
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), lifecycleName)
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ lifecycleList = append(lifecycleList, lifecycle)
+ }
+ if lifecycleNames != "" {
+ for _, lifecycleName := range strings.Split(lifecycleNames, ",") {
+ trimmedLifecycleName := strings.TrimSpace(lifecycleName)
+ lifecycle := utils.FindByJobName(lifecycles.GetResults(), trimmedLifecycleName)
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ lifecycleList = append(lifecycleList, lifecycle)
+ }
+ }
+
+ return lifecycleList
+}
+
+func validateLifecycleArguments(lifecycleName string, lifecycleNames string) {
+ if lifecycleName == "" && lifecycleNames == "" {
+ utils.PrintlnError(fmt.Errorf("use either --lifecycle \"\" or --lifecycles \", \" but not both at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if lifecycleName != "" && lifecycleNames != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --lifecycle and --lifecycles at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
+
+func init() {
+ lifecycleCmd.AddCommand(lifecycleStopCmd)
+ lifecycleStopCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleStopCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleStopCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleStopCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleStopCmd.Flags().StringVarP(&lifecycleNames, "lifecycles", "", "", "Lifecycle Job Names (comma separated) (ex: --lifecycles \"lifecycle1,lifecycle2\")")
+ lifecycleStopCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch lifecycle status until it's ready or an error occurs")
+}
diff --git a/cmd/lifecycle_update.go b/cmd/lifecycle_update.go
new file mode 100644
index 00000000..71164091
--- /dev/null
+++ b/cmd/lifecycle_update.go
@@ -0,0 +1,119 @@
+package cmd
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "context"
+
+ "github.com/pkg/errors"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var lifecycleUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update a lifecycle",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if (lifecycleTag != "" || lifecycleImageName != "") && lifecycleBranch != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --tag or --image-name with --branch at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if lifecycleTag == "" && lifecycleImageName == "" && lifecycleBranch == "" {
+ utils.PrintlnError(fmt.Errorf("you must use --tag or --image-name or --branch"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycles, err := ListLifecycleJobs(envId, client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ lifecycle := utils.FindByJobName(lifecycles, lifecycleName)
+
+ if lifecycle == nil || lifecycle.LifecycleJobResponse == nil {
+ utils.PrintlnError(fmt.Errorf("lifecycle %s not found", lifecycleName))
+ utils.PrintlnInfo("You can list all lifecycles with: qovery lifecycle list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ var docker = utils.GetJobDocker(lifecycle)
+ var image = utils.GetJobImage(lifecycle)
+
+ if docker != nil && (lifecycleTag != "" || lifecycleImageName != "") {
+ utils.PrintlnError(fmt.Errorf("you can't use --tag or --image-name with a lifecycle targetting a Dockerfile. Use --branch instead"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if image != nil && lifecycleBranch != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --branch with a lifecycle targetting an image. Use --tag and/or --image-name instead"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ req := utils.ToJobRequest(*lifecycle)
+
+ if docker != nil {
+ req.Source.Docker.Get().GitRepository.Branch = &lifecycleBranch
+ req.Source.Image.Set(nil)
+ } else {
+ if lifecycleTag != "" {
+ req.Source.Image.Get().Tag = &lifecycleTag
+ }
+ if lifecycleImageName != "" {
+ req.Source.Image.Get().ImageName = &lifecycleImageName
+ }
+ req.Source.Docker.Set(nil)
+ }
+
+ _, res, err := client.JobMainCallsAPI.EditJob(context.Background(), lifecycle.LifecycleJobResponse.Id).JobRequest(req).Execute()
+
+ if err != nil {
+ result, _ := io.ReadAll(res.Body)
+ utils.PrintlnError(errors.Errorf("status code: %s ; body: %s", res.Status, string(result)))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Lifecycle %s updated!", pterm.FgBlue.Sprintf("%s", lifecycleName)))
+ },
+}
+
+func init() {
+ lifecycleCmd.AddCommand(lifecycleUpdateCmd)
+ lifecycleUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ lifecycleUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ lifecycleUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ lifecycleUpdateCmd.Flags().StringVarP(&lifecycleName, "lifecycle", "n", "", "Lifecycle Name")
+ lifecycleUpdateCmd.Flags().StringVarP(&lifecycleBranch, "branch", "b", "", "Lifecycle Branch")
+ lifecycleUpdateCmd.Flags().StringVarP(&lifecycleTag, "tag", "t", "", "Lifecycle Tag")
+ lifecycleUpdateCmd.Flags().StringVarP(&lifecycleImageName, "image-name", "", "", "Lifecycle Image Name")
+}
diff --git a/cmd/log.go b/cmd/log.go
index b6f29f35..91a9ee74 100644
--- a/cmd/log.go
+++ b/cmd/log.go
@@ -1,118 +1,133 @@
package cmd
import (
+ "context"
"errors"
_ "fmt"
- "github.com/olekukonko/tablewriter"
+ "os"
+
+ "github.com/qovery/qovery-cli/pkg"
"github.com/qovery/qovery-cli/utils"
- "github.com/qovery/qovery-client-go"
"github.com/spf13/cobra"
- "golang.org/x/net/context"
- "os"
- "time"
)
-var follow bool
+var (
+ rawFormat bool
+ logJobName string
+ logServiceName string
+ logServiceId string
+)
var logCmd = &cobra.Command{
Use: "log",
Short: "Print your application logs",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)
- var logs = getLogs()
-
- table := setupTable(true)
- table.AppendBulk(logs)
- table.Render()
-
- if len(logs) <= 0 {
- utils.PrintlnInfo("No logs found. ")
- os.Exit(0)
- }
-
- var lastRenderedLogs = logs
-
- for follow {
- table := setupTable(false)
-
- lastLogDateString := lastRenderedLogs[len(lastRenderedLogs)-1][0]
- lastLogDate, _ := time.Parse(time.StampMicro, lastLogDateString)
- var newLogs = getLogs()
-
- if len(newLogs) > 0 {
- for _, newLog := range newLogs {
- newLogDate, _ := time.Parse(time.StampMicro, newLog[0])
- if lastLogDate.Before(newLogDate) {
- table.Append(newLog)
- }
- }
- table.Render()
- lastRenderedLogs = newLogs
- }
-
- time.Sleep(time.Second * 5)
- }
+ getLogs()
},
}
-func getLogs() [][]string {
- token, err := utils.GetAccessToken()
+func getLogs() string {
+ tokenType, token, err := utils.GetAccessToken(false)
if err != nil {
utils.PrintlnError(err)
- os.Exit(0)
+ os.Exit(1)
}
- application, _, err := utils.CurrentApplication()
+ client := utils.GetQoveryClient(tokenType, token)
+
+ var service *utils.Service
+
+ orgID, projectID, envID, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
if err != nil {
utils.PrintlnError(err)
- os.Exit(0)
+ os.Exit(1)
}
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
+ switch {
+ case logServiceId != "":
+ service = &utils.Service{ID: utils.Id(logServiceId)}
+ case applicationName != "":
+ app, err := getApplicationContextResource(client, applicationName, envID)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ service = &utils.Service{ID: utils.Id(app.Id), Name: utils.Name(app.Name), Type: utils.ApplicationType}
+ case containerName != "":
+ container, err := getContainerContextResource(client, containerName, envID)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ service = &utils.Service{ID: utils.Id(container.Id), Name: utils.Name(container.Name), Type: utils.ContainerType}
+ case databaseName != "":
+ db, err := getDatabaseContextResource(client, databaseName, envID)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ service = &utils.Service{ID: utils.Id(db.Id), Name: utils.Name(db.Name), Type: utils.DatabaseType}
+ case logJobName != "":
+ job, err := getJobContextResource(client, logJobName, envID)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ if job.CronJobResponse != nil {
+ service = &utils.Service{ID: utils.Id(job.CronJobResponse.Id), Name: utils.Name(job.CronJobResponse.Name), Type: utils.JobType}
+ } else if job.LifecycleJobResponse != nil {
+ service = &utils.Service{ID: utils.Id(job.LifecycleJobResponse.Id), Name: utils.Name(job.LifecycleJobResponse.Name), Type: utils.JobType}
+ }
+ case logServiceName != "":
+ svc, err := getServiceContextResourceId(client, logServiceName, envID)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ service = svc
+ default:
+ service, err = utils.CurrentService(true)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+ }
- logs, res, err := client.ApplicationLogsApi.ListApplicationLog(auth, string(application)).Execute()
+ e, res, err := client.EnvironmentMainCallsAPI.GetEnvironment(context.Background(), envID).Execute()
if err != nil {
utils.PrintlnError(err)
- os.Exit(0)
+ os.Exit(1)
}
if res.StatusCode >= 400 {
- utils.PrintlnError(errors.New("Received " + res.Status + " response while listing organizations. "))
- }
-
- var logRows = make([][]string, 0)
-
- for _, log := range logs.GetResults() {
- logRows = append(logRows, []string{log.CreatedAt.Format(time.StampMicro), log.Message})
+ utils.PrintlnError(errors.New("Received " + res.Status + " response while fetching environment. "))
+ os.Exit(1)
}
- return logRows
-}
-
-func setupTable(header bool) *tablewriter.Table {
- table := tablewriter.NewWriter(os.Stdout)
-
- if header {
- table.SetHeader([]string{"TIME", "MESSAGE"})
+ req := pkg.LogRequest{
+ ServiceID: service.ID,
+ OrganizationID: utils.Id(orgID),
+ ProjectID: utils.Id(projectID),
+ EnvironmentID: utils.Id(envID),
+ ClusterID: utils.Id(e.ClusterId),
+ RawFormat: rawFormat,
}
- table.SetBorder(false)
- table.SetHeaderLine(false)
- table.SetColumnSeparator("")
- table.SetAutoWrapText(true)
- table.SetRowLine(false)
- table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
- table.SetColWidth(160)
- table.SetBorders(tablewriter.Border{
- Left: false,
- Right: false,
- Top: false,
- Bottom: false,
- })
+ pkg.ExecLog(&req)
- return table
+ // return logRows
+ return ""
}
func init() {
rootCmd.AddCommand(logCmd)
- logCmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow application logs")
+ logCmd.Flags().BoolVarP(&rawFormat, "raw", "r", false, "display logs in raw format (json)")
+ logCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ logCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ logCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ logCmd.Flags().StringVarP(&applicationName, "application", "a", "", "Application Name")
+ logCmd.Flags().StringVarP(&containerName, "container", "n", "", "Container Name")
+ logCmd.Flags().StringVarP(&databaseName, "database", "d", "", "Database Name")
+ logCmd.Flags().StringVarP(&logJobName, "job", "j", "", "Job Name")
+ logCmd.Flags().StringVarP(&logServiceName, "service", "s", "", "Service Name")
+ logCmd.Flags().StringVarP(&logServiceId, "service-id", "", "", "Service ID (UUID) - skips name lookup, use when you already have the ID from a console URL")
}
diff --git a/cmd/log_test.go b/cmd/log_test.go
new file mode 100644
index 00000000..e0ea2bfa
--- /dev/null
+++ b/cmd/log_test.go
@@ -0,0 +1,44 @@
+package cmd
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLogCmdFlags(t *testing.T) {
+ flags := []string{"organization", "project", "environment", "application", "container", "database", "job", "service", "raw"}
+ for _, name := range flags {
+ t.Run(name, func(t *testing.T) {
+ require.NotNil(t, logCmd.Flags().Lookup(name), "flag --%s should be registered", name)
+ })
+ }
+}
+
+func TestLogCmdUnknownFlag(t *testing.T) {
+ err := logCmd.ParseFlags([]string{"--unknown-flag", "value"})
+ assert.Error(t, err)
+}
+
+func TestLogCmdFlagParsing(t *testing.T) {
+ // Reset flags before parsing
+ _ = logCmd.Flags().Set("container", "")
+ _ = logCmd.Flags().Set("project", "")
+ _ = logCmd.Flags().Set("environment", "")
+
+ err := logCmd.ParseFlags([]string{"--container", "test", "--project", "Laura", "--environment", "keda"})
+ require.NoError(t, err)
+
+ got, err := logCmd.Flags().GetString("container")
+ require.NoError(t, err)
+ assert.Equal(t, "test", got)
+
+ got, err = logCmd.Flags().GetString("project")
+ require.NoError(t, err)
+ assert.Equal(t, "Laura", got)
+
+ got, err = logCmd.Flags().GetString("environment")
+ require.NoError(t, err)
+ assert.Equal(t, "keda", got)
+}
diff --git a/cmd/organization.go b/cmd/organization.go
new file mode 100644
index 00000000..eb3edc5f
--- /dev/null
+++ b/cmd/organization.go
@@ -0,0 +1,14 @@
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+)
+
+var organizationCmd = &cobra.Command{
+ Use: "organization",
+ Short: "Manage Organization",
+}
+
+func init() {
+ rootCmd.AddCommand(organizationCmd)
+}
diff --git a/cmd/organization_list.go b/cmd/organization_list.go
new file mode 100644
index 00000000..48eb9f11
--- /dev/null
+++ b/cmd/organization_list.go
@@ -0,0 +1,72 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/qovery/qovery-client-go"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var organizationListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List organizations the authenticated token has access to",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizations, _, err := client.OrganizationMainCallsAPI.ListOrganization(context.Background()).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(getOrganizationJsonOutput(organizations.GetResults()))
+ return
+ }
+
+ var data [][]string
+
+ for _, organization := range organizations.GetResults() {
+ data = append(data, []string{organization.Id, organization.GetName(), string(organization.GetPlan())})
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name", "Plan"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getOrganizationJsonOutput(organizations []qovery.Organization) string {
+ organizationJSON, err := json.Marshal(organizations)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(organizationJSON)
+}
+
+func init() {
+ organizationCmd.AddCommand(organizationListCmd)
+ organizationListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/port-forward.go b/cmd/port-forward.go
new file mode 100644
index 00000000..45e85cb8
--- /dev/null
+++ b/cmd/port-forward.go
@@ -0,0 +1,310 @@
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ log "github.com/sirupsen/logrus"
+ "os"
+ "os/signal"
+ "strconv"
+ "strings"
+ "syscall"
+ "context"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var portForwardCmd = &cobra.Command{
+ Use: "port-forward",
+ Short: "Port forward a port to an application container",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(ports) == 0 {
+ log.Fatal("port flag must be specified at least once")
+ return
+ }
+
+ var portForwardRequest *pkg.PortForwardRequest
+ var err error
+ if len(args) > 0 {
+ portForwardRequest, err = portForwardRequestWithApplicationUrl(args)
+ } else {
+ portForwardRequest, err = portForwardRequestWithoutArg()
+ }
+ if err != nil {
+ utils.PrintlnError(err)
+ return
+ }
+
+ for _, port := range ports {
+ ps := strings.Split(port, ":")
+ var localPortStr, remotePortStr string
+ if len(ps) > 1 {
+ localPortStr = ps[0]
+ remotePortStr = ps[1]
+ } else {
+ localPortStr = ps[0]
+ remotePortStr = ps[0]
+ }
+
+ localPort, err := strconv.ParseUint(localPortStr, 10, 16)
+ if err != nil {
+ log.Fatal("Invalid local port {} {}", port, err)
+ }
+
+ remotePort, err := strconv.ParseUint(remotePortStr, 10, 16)
+ if err != nil {
+ log.Fatal("Invalid remote port {} {}", port, err)
+ }
+
+ req := *portForwardRequest
+ req.LocalPort = uint16(localPort)
+ req.Port = uint16(remotePort)
+ go pkg.ExecPortForward(&req)
+ }
+
+ done := make(chan os.Signal, 1)
+ signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
+ <-done
+ },
+}
+var (
+ ports []string
+)
+
+func portForwardRequestWithoutArg() (*pkg.PortForwardRequest, error) {
+ useContext := false
+ currentContext, err := utils.GetCurrentContext()
+ if err != nil {
+ return nil, err
+ }
+
+ utils.PrintlnInfo("Current context:")
+ if currentContext.ServiceId != "" && currentContext.ServiceName != "" &&
+ currentContext.EnvironmentId != "" && currentContext.EnvironmentName != "" &&
+ currentContext.ProjectId != "" && currentContext.ProjectName != "" &&
+ currentContext.OrganizationId != "" && currentContext.OrganizationName != "" {
+ if err := utils.PrintContext(); err != nil {
+ fmt.Println("Context not yet configured.")
+ }
+ fmt.Println()
+
+ utils.PrintlnInfo("Continue with port-forward command using this context ?")
+ useContext = utils.Validate("context")
+ fmt.Println()
+ } else {
+ if err := utils.PrintContext(); err != nil {
+ fmt.Println("Context not yet configured.")
+ fmt.Println("Unable to use current context for `port-forward` command.")
+ fmt.Println()
+ }
+ }
+
+ var req *pkg.PortForwardRequest
+ if useContext {
+ req, err = portForwardRequestFromContext(currentContext)
+ } else {
+ req, err = portForwardRequestFromSelect()
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return req, nil
+}
+
+func portForwardRequestFromSelect() (*pkg.PortForwardRequest, error) {
+ utils.PrintlnInfo("Select organization")
+ org, err := utils.SelectOrganization()
+ if err != nil {
+ return nil, err
+ }
+
+ utils.PrintlnInfo("Select project")
+ project, err := utils.SelectProject(org.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ utils.PrintlnInfo("Select environment")
+ env, err := utils.SelectEnvironment(project.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ utils.PrintlnInfo("Select service")
+ service, err := utils.SelectService(env.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ return &pkg.PortForwardRequest{
+ ServiceID: service.ID,
+ ServiceType: strings.ToUpper(string(service.Type)),
+ ProjectID: project.ID,
+ OrganizationID: org.ID,
+ EnvironmentID: env.ID,
+ ClusterID: env.ClusterID,
+ PodName: podName,
+ Port: 0,
+ LocalPort: 0,
+ }, nil
+}
+
+func portForwardRequestFromContext(currentContext utils.QoveryContext) (*pkg.PortForwardRequest, error) {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ e, res, err := client.EnvironmentMainCallsAPI.GetEnvironment(context.Background(), string(currentContext.EnvironmentId)).Execute()
+ if err != nil {
+ return nil, err
+ }
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while fetching environment. ")
+ }
+
+ return &pkg.PortForwardRequest{
+ ServiceID: currentContext.ServiceId,
+ ServiceType: strings.ToUpper(string(currentContext.ServiceType)),
+ ProjectID: currentContext.ProjectId,
+ OrganizationID: currentContext.OrganizationId,
+ EnvironmentID: currentContext.EnvironmentId,
+ ClusterID: utils.Id(e.ClusterId),
+ PodName: podName,
+ Port: 0,
+ LocalPort: 0,
+ }, nil
+}
+
+func portForwardRequestWithApplicationUrl(args []string) (*pkg.PortForwardRequest, error) {
+ var url = args[0]
+ url = strings.Replace(url, "https://console.qovery.com/", "", 1)
+ url = strings.Replace(url, "https://new.console.qovery.com/", "", 1)
+ urlSplit := strings.Split(url, "/")
+
+ if len(urlSplit) < 8 {
+ return nil, errors.New("Wrong URL format: " + url)
+ }
+
+ var organizationId = urlSplit[1]
+ organization, err := utils.GetOrganizationById(organizationId)
+ if err != nil {
+ return nil, err
+ }
+
+ var projectId = urlSplit[3]
+ project, err := utils.GetProjectById(projectId)
+ if err != nil {
+ return nil, err
+ }
+
+ var environmentId = urlSplit[5]
+ environment, err := utils.GetEnvironmentById(environmentId)
+ if err != nil {
+ return nil, err
+ }
+
+ environmentServices, err := utils.GetEnvironmentServicesById(environmentId)
+ if err != nil {
+ return nil, err
+ }
+
+ var service utils.Service
+ var serviceId = urlSplit[7]
+ for _, envService := range environmentServices {
+ if envService.ID == serviceId {
+ switch envService.Type {
+
+ case utils.ApplicationType:
+ applicationAPI, err := utils.GetApplicationById(serviceId)
+ if err != nil {
+ return nil, err
+ }
+ service = utils.Service{
+ ID: applicationAPI.ID,
+ Name: applicationAPI.Name,
+ Type: utils.ApplicationType,
+ }
+
+ case utils.ContainerType:
+ containerAPI, err := utils.GetContainerById(serviceId)
+ if err != nil {
+ return nil, err
+ }
+ service = utils.Service{
+ ID: containerAPI.ID,
+ Name: containerAPI.Name,
+ Type: utils.ContainerType,
+ }
+
+ case utils.JobType:
+ jobAPI, err := utils.GetJobById(serviceId)
+ if err != nil {
+ return nil, err
+ }
+ service = utils.Service{
+ ID: jobAPI.ID,
+ Name: jobAPI.Name,
+ Type: utils.JobType,
+ }
+
+ case utils.DatabaseType:
+ db, err := utils.GetDatabaseById(serviceId)
+ if err != nil {
+ return nil, err
+ }
+ service = *db
+
+ case utils.HelmType:
+ helm, err := utils.GetHelmById(serviceId)
+ if err != nil {
+ return nil, err
+ }
+ service = *helm
+
+ default:
+ return nil, errors.New("ServiceLevel type `" + string(envService.Type) + "` is not supported for port-forward")
+ }
+ }
+ }
+
+ _ = pterm.DefaultTable.WithData(pterm.TableData{
+ {"Organization", string(organization.Name)},
+ {"Project", string(project.Name)},
+ {"Environment", string(environment.Name)},
+ {"ServiceLevel", string(service.Name)},
+ {"ServiceType", string(service.Type)},
+ }).Render()
+
+ return &pkg.PortForwardRequest{
+ OrganizationID: organization.ID,
+ ProjectID: project.ID,
+ EnvironmentID: environment.ID,
+ ServiceID: service.ID,
+ ServiceType: strings.ToUpper(string(service.Type)),
+ ClusterID: environment.ClusterID,
+ PodName: podName,
+ Port: 0,
+ LocalPort: 0,
+ }, nil
+}
+
+func init() {
+ var portForwardCmd = portForwardCmd
+ portForwardCmd.Flags().StringVarP(&podName, "pod", "", "", "pod name where to forward traffic")
+ portForwardCmd.Flags().StringSliceVarP(&ports, "port", "p", nil, "port that will be forwarded. Can be specified multiple time. Format \"local_port:remote_port\" i.e: 8080:80")
+ _ = portForwardCmd.MarkFlagRequired("port")
+
+ rootCmd.AddCommand(portForwardCmd)
+}
diff --git a/cmd/project.go b/cmd/project.go
new file mode 100644
index 00000000..de4ffcb2
--- /dev/null
+++ b/cmd/project.go
@@ -0,0 +1,14 @@
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+)
+
+var projectCmd = &cobra.Command{
+ Use: "project",
+ Short: "Manage Project",
+}
+
+func init() {
+ rootCmd.AddCommand(projectCmd)
+}
diff --git a/cmd/project_env.go b/cmd/project_env.go
new file mode 100644
index 00000000..bf00b739
--- /dev/null
+++ b/cmd/project_env.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var projectEnvCmd = &cobra.Command{
+ Use: "env",
+ Short: "Manage project variables and secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ projectCmd.AddCommand(projectEnvCmd)
+}
diff --git a/cmd/project_env_alias.go b/cmd/project_env_alias.go
new file mode 100644
index 00000000..7655e086
--- /dev/null
+++ b/cmd/project_env_alias.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var projectEnvAliasCmd = &cobra.Command{
+ Use: "alias",
+ Short: "Manage project variable and secret aliases",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ projectEnvCmd.AddCommand(projectEnvAliasCmd)
+}
diff --git a/cmd/project_env_alias_create.go b/cmd/project_env_alias_create.go
new file mode 100644
index 00000000..bb051417
--- /dev/null
+++ b/cmd/project_env_alias_create.go
@@ -0,0 +1,48 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var projectEnvAliasCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create project variable or secret alias",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+
+ err = utils.CreateProjectAlias(client, project.Id, utils.Key, utils.Alias)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("Alias %s has been created", pterm.FgBlue.Sprintf("%s", utils.Alias)))
+ },
+}
+
+func init() {
+ projectEnvAliasCmd.AddCommand(projectEnvAliasCreateCmd)
+ projectEnvAliasCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ projectEnvAliasCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ projectEnvAliasCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Project variable or secret key")
+ projectEnvAliasCreateCmd.Flags().StringVarP(&utils.Alias, "alias", "", "", "Project variable or secret alias")
+
+ _ = projectEnvAliasCreateCmd.MarkFlagRequired("project")
+ _ = projectEnvAliasCreateCmd.MarkFlagRequired("key")
+ _ = projectEnvAliasCreateCmd.MarkFlagRequired("alias")
+}
diff --git a/cmd/project_env_create.go b/cmd/project_env_create.go
new file mode 100644
index 00000000..f9f0d7b3
--- /dev/null
+++ b/cmd/project_env_create.go
@@ -0,0 +1,49 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var projectEnvCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create project variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+
+ err = utils.CreateProjectVariable(client, project.Id, utils.Key, utils.Value, utils.IsSecret)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("Project variable %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ projectEnvCmd.AddCommand(projectEnvCreateCmd)
+ projectEnvCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ projectEnvCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ projectEnvCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Project variable or secret key")
+ projectEnvCreateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Project variable or secret value")
+ projectEnvCreateCmd.Flags().BoolVarP(&utils.IsSecret, "secret", "", false, "This Project variable is a secret")
+
+ _ = projectEnvCreateCmd.MarkFlagRequired("project")
+ _ = projectEnvCreateCmd.MarkFlagRequired("key")
+ _ = projectEnvCreateCmd.MarkFlagRequired("value")
+}
diff --git a/cmd/project_env_delete.go b/cmd/project_env_delete.go
new file mode 100644
index 00000000..71c4bd2e
--- /dev/null
+++ b/cmd/project_env_delete.go
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var projectEnvDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete project variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+
+ err = utils.DeleteProjectVar(client, project.Id, utils.Key)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("Variable %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ projectEnvCmd.AddCommand(projectEnvDeleteCmd)
+ projectEnvDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ projectEnvDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ projectEnvDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Project variable or secret key")
+
+ _ = projectEnvDeleteCmd.MarkFlagRequired("project")
+ _ = projectEnvDeleteCmd.MarkFlagRequired("key")
+}
diff --git a/cmd/project_env_list.go b/cmd/project_env_list.go
new file mode 100644
index 00000000..bf626e9f
--- /dev/null
+++ b/cmd/project_env_list.go
@@ -0,0 +1,59 @@
+package cmd
+
+import (
+ "context"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var projectEnvListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List project variables",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+ envVars, err := utils.ListProjectVariables(client, project.Id)
+ checkError(err)
+
+ envVarLines := utils.NewEnvVarLines()
+ var variables []utils.EnvVarLineOutput
+
+ for _, envVar := range envVars {
+ s := utils.FromEnvironmentVariableToEnvVarLineOutput(envVar)
+ variables = append(variables, s)
+ envVarLines.Add(s)
+ }
+
+ if jsonFlag {
+ utils.Println(utils.GetEnvVarJsonOutput(variables, utils.SortKeys))
+ return
+ }
+
+ err = utils.PrintTable(envVarLines.Header(utils.PrettyPrint), envVarLines.Lines(utils.ShowValues, utils.PrettyPrint, utils.SortKeys))
+ checkError(err)
+ },
+}
+
+func init() {
+ projectEnvCmd.AddCommand(projectEnvListCmd)
+ projectEnvListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ projectEnvListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ projectEnvListCmd.Flags().BoolVarP(&utils.ShowValues, "show-values", "", false, "Show env var values")
+ projectEnvListCmd.Flags().BoolVarP(&utils.PrettyPrint, "pretty-print", "", false, "Pretty print output")
+ projectEnvListCmd.Flags().BoolVarP(&utils.SortKeys, "sort", "", false, "Sort environment variables by key")
+ projectEnvListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+
+ _ = projectEnvListCmd.MarkFlagRequired("project")
+}
diff --git a/cmd/project_env_update.go b/cmd/project_env_update.go
new file mode 100644
index 00000000..6eb2d32d
--- /dev/null
+++ b/cmd/project_env_update.go
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var projectEnvUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update project variable or secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ checkError(err)
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, _, _, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+ err = utils.UpdateProjectVariable(client, project.Id, utils.Key, utils.Value)
+ checkError(err)
+
+ utils.Println(fmt.Sprintf("Project variable %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ projectEnvCmd.AddCommand(projectEnvUpdateCmd)
+ projectEnvUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ projectEnvUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ projectEnvUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "Project variable or secret key")
+ projectEnvUpdateCmd.Flags().StringVarP(&utils.Value, "value", "v", "", "Project variable or secret value")
+
+ _ = projectEnvUpdateCmd.MarkFlagRequired("project")
+ _ = projectEnvUpdateCmd.MarkFlagRequired("key")
+ _ = projectEnvUpdateCmd.MarkFlagRequired("value")
+}
diff --git a/cmd/project_list.go b/cmd/project_list.go
new file mode 100644
index 00000000..01e9cf0a
--- /dev/null
+++ b/cmd/project_list.go
@@ -0,0 +1,81 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/qovery/qovery-client-go"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var projectListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List projects",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ utils.Println(getProjectJsonOutput(projects.GetResults()))
+ return
+ }
+
+ var data [][]string
+
+ for _, project := range projects.GetResults() {
+ data = append(data, []string{project.Id, project.GetName()})
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getProjectJsonOutput(projects []qovery.Project) string {
+ projectJSON, err := json.Marshal(projects)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(projectJSON)
+}
+
+func init() {
+ projectCmd.AddCommand(projectListCmd)
+ projectListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ projectListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/rde.go b/cmd/rde.go
new file mode 100644
index 00000000..2ba75d7d
--- /dev/null
+++ b/cmd/rde.go
@@ -0,0 +1,580 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+// RDE env var constants
+const rdeBlueprintProjectIdVar = "BLUEPRINT_PROJECT_ID"
+const rdeBlueprintKeyVar = "BLUEPRINT_KEY"
+const rdeOwnerEmailVar = "RDE_OWNER_EMAIL"
+
+// RDE shared flag variables
+var rdeBlueprintProjectName string
+var rdeName string
+var rdeEmail string
+var rdeSkipRbac bool
+var rdeSkipInvite bool
+var rdeSkipDeploy bool
+var rdeUpgradeStrategy string
+var rdeConfirmFlag bool
+
+var rdeCmd = &cobra.Command{
+ Use: "rde",
+ Short: "Manage Remote Development Environments (RDE)",
+ Long: `Manage Remote Development Environments (RDE).
+
+RDE allows platform teams to provision isolated, pre-configured development
+environments for developers. The system works as:
+
+ 1. Blueprints: Template projects/environments that serve as the source for cloning
+ 2. RDE instances: Cloned from a blueprint, with optional RBAC isolation and member invitation
+
+Blueprint identification uses environment variables:
+ - Project-level: BLUEPRINT_PROJECT_ID = (marks a project as a blueprint)
+ - Environment-level: BLUEPRINT_KEY = (links environments to their blueprint)`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(rdeCmd)
+}
+
+// --- RDE helper types ---
+
+type rdeBlueprintInfo struct {
+ ProjectId string
+ ProjectName string
+ EnvId string
+ EnvName string
+}
+
+type rdeChildInfo struct {
+ ProjectId string
+ ProjectName string
+ EnvId string
+ EnvName string
+ BlueprintProjectId string
+ OwnerEmail string
+}
+
+// --- RDE helper functions ---
+
+// rdeGetOrgId resolves the organization ID from the --organization flag or stored context.
+func rdeGetOrgId(client *qovery.APIClient) (string, error) {
+ return usercontext.GetOrganizationContextResourceId(client, organizationName)
+}
+
+// rdeListBlueprintProjects finds all projects in the org that have the BLUEPRINT_PROJECT_ID env var.
+func rdeListBlueprintProjects(client *qovery.APIClient, orgId string) ([]rdeBlueprintInfo, error) {
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), orgId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ var blueprints []rdeBlueprintInfo
+
+ for _, project := range projects.GetResults() {
+ vars, err := utils.ListProjectVariables(client, project.Id)
+ if err != nil {
+ continue // skip projects we can't read vars for
+ }
+
+ bpVar := utils.FindEnvironmentVariableByKey(rdeBlueprintProjectIdVar, vars)
+ if bpVar == nil {
+ continue
+ }
+
+ // Verify the var value matches this project's ID
+ val := ""
+ if bpVar.Value.IsSet() && bpVar.Value.Get() != nil {
+ val = *bpVar.Value.Get()
+ }
+ if val != project.Id {
+ continue
+ }
+
+ // Find the first environment that has BLUEPRINT_KEY == projectId
+ envInfo, err := rdeFindBlueprintEnv(client, project.Id)
+ if err != nil || envInfo == nil {
+ // Blueprint project with no matching environment yet
+ blueprints = append(blueprints, rdeBlueprintInfo{
+ ProjectId: project.Id,
+ ProjectName: project.Name,
+ })
+ continue
+ }
+
+ blueprints = append(blueprints, rdeBlueprintInfo{
+ ProjectId: project.Id,
+ ProjectName: project.Name,
+ EnvId: envInfo.EnvId,
+ EnvName: envInfo.EnvName,
+ })
+ }
+
+ return blueprints, nil
+}
+
+// rdeFindBlueprintByProjectName finds a specific blueprint project by name.
+func rdeFindBlueprintByProjectName(client *qovery.APIClient, orgId string, name string) (*rdeBlueprintInfo, error) {
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), orgId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, project := range projects.GetResults() {
+ if !strings.EqualFold(project.Name, name) {
+ continue
+ }
+
+ vars, err := utils.ListProjectVariables(client, project.Id)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read variables for project %s: %w", name, err)
+ }
+
+ bpVar := utils.FindEnvironmentVariableByKey(rdeBlueprintProjectIdVar, vars)
+ if bpVar == nil {
+ return nil, fmt.Errorf("project %s is not a blueprint (missing %s variable)", name, rdeBlueprintProjectIdVar)
+ }
+
+ val := ""
+ if bpVar.Value.IsSet() && bpVar.Value.Get() != nil {
+ val = *bpVar.Value.Get()
+ }
+ if val != project.Id {
+ return nil, fmt.Errorf("project %s has invalid %s variable (expected %s, got %s)", name, rdeBlueprintProjectIdVar, project.Id, val)
+ }
+
+ envInfo, err := rdeFindBlueprintEnv(client, project.Id)
+ if err != nil {
+ return nil, err
+ }
+
+ info := &rdeBlueprintInfo{
+ ProjectId: project.Id,
+ ProjectName: project.Name,
+ }
+ if envInfo != nil {
+ info.EnvId = envInfo.EnvId
+ info.EnvName = envInfo.EnvName
+ }
+
+ return info, nil
+ }
+
+ return nil, fmt.Errorf("project %s not found", name)
+}
+
+type envInfo struct {
+ EnvId string
+ EnvName string
+}
+
+// rdeFindBlueprintEnv gets the first environment in a blueprint project that has BLUEPRINT_KEY == projectId.
+func rdeFindBlueprintEnv(client *qovery.APIClient, projectId string) (*envInfo, error) {
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), projectId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, env := range environments.GetResults() {
+ vars, err := utils.ListEnvironmentVariables(client, env.Id)
+ if err != nil {
+ continue
+ }
+
+ bkVar := utils.FindEnvironmentVariableByKey(rdeBlueprintKeyVar, vars)
+ if bkVar == nil {
+ continue
+ }
+
+ val := ""
+ if bkVar.Value.IsSet() && bkVar.Value.Get() != nil {
+ val = *bkVar.Value.Get()
+ }
+ if val == projectId {
+ return &envInfo{EnvId: env.Id, EnvName: env.Name}, nil
+ }
+ }
+
+ return nil, nil
+}
+
+// rdeListChildren finds all projects whose environments have BLUEPRINT_KEY == blueprintProjectId (excluding the blueprint itself).
+func rdeListChildren(client *qovery.APIClient, orgId string, blueprintProjectId string) ([]rdeChildInfo, error) {
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), orgId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ var children []rdeChildInfo
+
+ for _, project := range projects.GetResults() {
+ if project.Id == blueprintProjectId {
+ continue // skip the blueprint itself
+ }
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ if err != nil {
+ continue
+ }
+
+ for _, env := range environments.GetResults() {
+ vars, err := utils.ListEnvironmentVariables(client, env.Id)
+ if err != nil {
+ continue
+ }
+
+ bkVar := utils.FindEnvironmentVariableByKey(rdeBlueprintKeyVar, vars)
+ if bkVar == nil {
+ continue
+ }
+
+ val := ""
+ if bkVar.Value.IsSet() && bkVar.Value.Get() != nil {
+ val = *bkVar.Value.Get()
+ }
+ if val == blueprintProjectId {
+ ownerEmail := ""
+ ownerVar := utils.FindEnvironmentVariableByKey(rdeOwnerEmailVar, vars)
+ if ownerVar != nil && ownerVar.Value.IsSet() && ownerVar.Value.Get() != nil {
+ ownerEmail = *ownerVar.Value.Get()
+ }
+ children = append(children, rdeChildInfo{
+ ProjectId: project.Id,
+ ProjectName: project.Name,
+ EnvId: env.Id,
+ EnvName: env.Name,
+ BlueprintProjectId: blueprintProjectId,
+ OwnerEmail: ownerEmail,
+ })
+ }
+ }
+ }
+
+ return children, nil
+}
+
+// rdeListAllChildren finds all RDE children across all blueprints.
+func rdeListAllChildren(client *qovery.APIClient, orgId string) ([]rdeChildInfo, error) {
+ blueprints, err := rdeListBlueprintProjects(client, orgId)
+ if err != nil {
+ return nil, err
+ }
+
+ var allChildren []rdeChildInfo
+ for _, bp := range blueprints {
+ children, err := rdeListChildren(client, orgId, bp.ProjectId)
+ if err != nil {
+ continue
+ }
+ allChildren = append(allChildren, children...)
+ }
+
+ return allChildren, nil
+}
+
+// rdeFindChildByName finds a child RDE project by its project name within the org.
+func rdeFindChildByName(client *qovery.APIClient, orgId string, name string) (*rdeChildInfo, error) {
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), orgId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, project := range projects.GetResults() {
+ if !strings.EqualFold(project.Name, name) {
+ continue
+ }
+
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ if err != nil {
+ return nil, fmt.Errorf("failed to list environments for project %s: %w", name, err)
+ }
+
+ for _, env := range environments.GetResults() {
+ vars, err := utils.ListEnvironmentVariables(client, env.Id)
+ if err != nil {
+ continue
+ }
+
+ bkVar := utils.FindEnvironmentVariableByKey(rdeBlueprintKeyVar, vars)
+ if bkVar == nil {
+ continue
+ }
+
+ val := ""
+ if bkVar.Value.IsSet() && bkVar.Value.Get() != nil {
+ val = *bkVar.Value.Get()
+ }
+
+ // It's a child if BLUEPRINT_KEY != own project ID
+ if val != "" && val != project.Id {
+ ownerEmail := ""
+ ownerVar := utils.FindEnvironmentVariableByKey(rdeOwnerEmailVar, vars)
+ if ownerVar != nil && ownerVar.Value.IsSet() && ownerVar.Value.Get() != nil {
+ ownerEmail = *ownerVar.Value.Get()
+ }
+ return &rdeChildInfo{
+ ProjectId: project.Id,
+ ProjectName: project.Name,
+ EnvId: env.Id,
+ EnvName: env.Name,
+ BlueprintProjectId: val,
+ OwnerEmail: ownerEmail,
+ }, nil
+ }
+ }
+
+ return nil, fmt.Errorf("project %s exists but is not an RDE child (no %s variable pointing to a different project)", name, rdeBlueprintKeyVar)
+ }
+
+ return nil, fmt.Errorf("project %s not found", name)
+}
+
+// rdeGetEnvStatus gets the environment status as a StateEnum.
+func rdeGetEnvStatus(client *qovery.APIClient, envId string) (qovery.StateEnum, error) {
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+ if err != nil {
+ return "", err
+ }
+ return statuses.Environment.State, nil
+}
+
+// rdeGetWorkspaceUrl gets the first application's public URL from the environment.
+func rdeGetWorkspaceUrl(client *qovery.APIClient, envId string) string {
+ apps, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+ if err != nil || len(apps.GetResults()) == 0 {
+ return ""
+ }
+
+ appId := apps.GetResults()[0].Id
+ links, _, err := client.ApplicationMainCallsAPI.ListApplicationLinks(context.Background(), appId).Execute()
+ if err != nil || len(links.GetResults()) == 0 {
+ return ""
+ }
+
+ url := links.GetResults()[0].GetUrl()
+ return url
+}
+
+// rdeFormatUptime formats a deployment timestamp to human-readable uptime.
+func rdeFormatUptime(deployedAt *time.Time) string {
+ if deployedAt == nil {
+ return "-"
+ }
+
+ diff := time.Since(*deployedAt)
+ if diff < time.Minute {
+ return fmt.Sprintf("%ds", int(diff.Seconds()))
+ } else if diff < time.Hour {
+ return fmt.Sprintf("%dm", int(diff.Minutes()))
+ } else if diff < 24*time.Hour {
+ h := int(diff.Hours())
+ m := int(diff.Minutes()) % 60
+ return fmt.Sprintf("%dh %dm", h, m)
+ }
+ d := int(diff.Hours()) / 24
+ h := int(diff.Hours()) % 24
+ return fmt.Sprintf("%dd %dh", d, h)
+}
+
+// rdeGetLastDeployTime gets the last deployment timestamp for an environment.
+func rdeGetLastDeployTime(client *qovery.APIClient, envId string) *time.Time {
+ history, _, err := client.EnvironmentDeploymentHistoryAPI.ListEnvironmentDeploymentHistory(context.Background(), envId).Execute()
+ if err != nil || len(history.GetResults()) == 0 {
+ return nil
+ }
+
+ t := history.GetResults()[0].GetCreatedAt()
+ return &t
+}
+
+// rdeFindProjectByName finds a project by its exact name (case-insensitive) in the org.
+func rdeFindProjectByName(client *qovery.APIClient, orgId string, name string) (*qovery.Project, error) {
+ projects, _, err := client.ProjectsAPI.ListProject(context.Background(), orgId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, project := range projects.GetResults() {
+ if strings.EqualFold(project.Name, name) {
+ return &project, nil
+ }
+ }
+
+ return nil, fmt.Errorf("project %s not found", name)
+}
+
+// rdeFindCustomRoleByName finds a custom role by name in the org.
+func rdeFindCustomRoleByName(client *qovery.APIClient, orgId string, roleName string) (*qovery.OrganizationCustomRole, error) {
+ roles, _, err := client.OrganizationCustomRoleAPI.ListOrganizationCustomRoles(context.Background(), orgId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, role := range roles.GetResults() {
+ if role.Name != nil && strings.EqualFold(*role.Name, roleName) {
+ return &role, nil
+ }
+ }
+
+ return nil, nil
+}
+
+// rdePrintEnvServices prints the services and their statuses for an environment as a table.
+func rdePrintEnvServices(client *qovery.APIClient, envId string) {
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+ if err != nil {
+ utils.Println(" (could not retrieve statuses)")
+ return
+ }
+
+ // Build a name map from all service types
+ nameMap := make(map[string]string)
+
+ apps, _, _ := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+ if apps != nil {
+ for _, app := range apps.GetResults() {
+ nameMap[app.Id] = app.GetName()
+ }
+ }
+
+ containers, _, _ := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+ if containers != nil {
+ for _, c := range containers.GetResults() {
+ nameMap[c.Id] = c.Name
+ }
+ }
+
+ jobs, _, _ := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+ if jobs != nil {
+ for _, j := range jobs.GetResults() {
+ nameMap[utils.GetJobId(&j)] = utils.GetJobName(&j)
+ }
+ }
+
+ databases, _, _ := client.DatabasesAPI.ListDatabase(context.Background(), envId).Execute()
+ if databases != nil {
+ for _, db := range databases.GetResults() {
+ nameMap[db.Id] = db.Name
+ }
+ }
+
+ helms, _, _ := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+ if helms != nil {
+ for _, h := range helms.GetResults() {
+ nameMap[h.Id] = h.Name
+ }
+ }
+
+ var data [][]string
+ collectStatuses := func(statuses []qovery.Status, typeName string) {
+ for _, s := range statuses {
+ name := nameMap[s.Id]
+ if name == "" {
+ name = s.Id
+ }
+ data = append(data, []string{name, typeName, utils.GetStatusTextWithColor(s.State)})
+ }
+ }
+
+ collectStatuses(statuses.GetApplications(), "Application")
+ collectStatuses(statuses.GetContainers(), "Container")
+ collectStatuses(statuses.GetJobs(), "Job")
+ collectStatuses(statuses.GetDatabases(), "Database")
+ collectStatuses(statuses.GetHelms(), "Helm")
+
+ if len(data) == 0 {
+ utils.Println(" No services found.")
+ return
+ }
+
+ _ = utils.PrintTable([]string{"Name", "Type", "Status"}, data)
+}
+
+// rdePrintKeyValueTable renders a key-value pterm table (no headers, like PrintContext).
+func rdePrintKeyValueTable(rows [][]string) {
+ tableData := pterm.TableData{}
+ for _, row := range rows {
+ tableData = append(tableData, row)
+ }
+ _ = pterm.DefaultTable.WithData(tableData).Render()
+}
+
+// ctx is a shorthand for context.Background() used in RDE commands.
+func ctx() context.Context {
+ return context.Background()
+}
+
+// rdeBlueprintNameForProjectId resolves a blueprint project ID to its project name.
+func rdeBlueprintNameForProjectId(client *qovery.APIClient, projectId string) string {
+ project, _, err := client.ProjectMainCallsAPI.GetProject(context.Background(), projectId).Execute()
+ if err != nil {
+ return projectId // fallback to ID
+ }
+ return project.Name
+}
+
+// rdeGetBlueprintClusterId returns the cluster ID of the blueprint environment.
+func rdeGetBlueprintClusterId(client *qovery.APIClient, blueprintEnvId string) string {
+ env, _, err := client.EnvironmentMainCallsAPI.GetEnvironment(ctx(), blueprintEnvId).Execute()
+ if err != nil {
+ return ""
+ }
+ return env.ClusterId
+}
+
+// rdeIsEnvDeleted checks if an environment no longer exists (404) or is in a terminal deleted state.
+func rdeIsEnvDeleted(client *qovery.APIClient, envId string) bool {
+ _, resp, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(ctx(), envId).Execute()
+ if err != nil {
+ // If 404, it's deleted
+ if resp != nil && resp.StatusCode == 404 {
+ return true
+ }
+ return false
+ }
+ return false
+}
+
+// rdeWaitForEnvsDeletion waits for multiple environments to be deleted, polling until they return 404 or timeout.
+func rdeWaitForEnvsDeletion(client *qovery.APIClient, envIds []string, timeout time.Duration) {
+ deadline := time.Now().Add(timeout)
+ remaining := make(map[string]bool)
+ for _, id := range envIds {
+ remaining[id] = true
+ }
+
+ for len(remaining) > 0 && time.Now().Before(deadline) {
+ for id := range remaining {
+ if rdeIsEnvDeleted(client, id) {
+ delete(remaining, id)
+ }
+ }
+ if len(remaining) > 0 {
+ time.Sleep(5 * time.Second)
+ }
+ }
+
+ if len(remaining) > 0 {
+ utils.PrintlnInfo(fmt.Sprintf("%d environment(s) did not finish deleting within timeout, proceeding anyway", len(remaining)))
+ }
+}
diff --git a/cmd/rde_blueprint.go b/cmd/rde_blueprint.go
new file mode 100644
index 00000000..5265130f
--- /dev/null
+++ b/cmd/rde_blueprint.go
@@ -0,0 +1,29 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var rdeBlueprintCmd = &cobra.Command{
+ Use: "blueprint",
+ Short: "Manage RDE blueprints",
+ Long: `Manage RDE blueprint projects and environments.
+
+A blueprint is a project with a template environment that serves as the source
+for cloning new Remote Development Environments. Blueprints are identified by
+a project-level environment variable BLUEPRINT_PROJECT_ID.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeBlueprintCmd)
+}
diff --git a/cmd/rde_blueprint_deploy.go b/cmd/rde_blueprint_deploy.go
new file mode 100644
index 00000000..b8001591
--- /dev/null
+++ b/cmd/rde_blueprint_deploy.go
@@ -0,0 +1,61 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeBlueprintDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy a blueprint environment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if bp.EnvId == "" {
+ utils.PrintlnError(fmt.Errorf("blueprint %s has no environment with %s set", bp.ProjectName, rdeBlueprintKeyVar))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ _, _, err = client.EnvironmentActionsAPI.DeployEnvironment(context.Background(), bp.EnvId).Execute()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("deploy failed: %w", err))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Request to deploy blueprint %s has been queued..", pterm.FgBlue.Sprintf("%s", bp.ProjectName)))
+
+ if watchFlag {
+ time.Sleep(3 * time.Second)
+ utils.WatchEnvironment(bp.EnvId, qovery.STATEENUM_DEPLOYED, client)
+ }
+ },
+}
+
+func init() {
+ rdeBlueprintCmd.AddCommand(rdeBlueprintDeployCmd)
+ rdeBlueprintDeployCmd.Flags().StringVarP(&rdeBlueprintProjectName, "project", "p", "", "Blueprint Project Name")
+ rdeBlueprintDeployCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ rdeBlueprintDeployCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch deployment status until it's ready or an error occurs")
+
+ _ = rdeBlueprintDeployCmd.MarkFlagRequired("project")
+}
diff --git a/cmd/rde_blueprint_list.go b/cmd/rde_blueprint_list.go
new file mode 100644
index 00000000..8292269e
--- /dev/null
+++ b/cmd/rde_blueprint_list.go
@@ -0,0 +1,86 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var rdeBlueprintListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List all RDE blueprints",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ blueprints, err := rdeListBlueprintProjects(client, orgId)
+ checkError(err)
+
+ if len(blueprints) == 0 {
+ utils.Println("No RDE blueprints found.")
+ return
+ }
+
+ if jsonFlag {
+ var results []interface{}
+ for _, bp := range blueprints {
+ status := ""
+ if bp.EnvId != "" {
+ s, err := rdeGetEnvStatus(client, bp.EnvId)
+ if err == nil {
+ status = string(s)
+ }
+ }
+ results = append(results, map[string]interface{}{
+ "project_id": bp.ProjectId,
+ "project_name": bp.ProjectName,
+ "env_id": bp.EnvId,
+ "env_name": bp.EnvName,
+ "status": status,
+ })
+ }
+ j, _ := json.Marshal(results)
+ utils.Println(string(j))
+ return
+ }
+
+ var data [][]string
+ for _, bp := range blueprints {
+ status := "NO_ENV"
+ if bp.EnvId != "" {
+ s, err := rdeGetEnvStatus(client, bp.EnvId)
+ if err == nil {
+ status = string(utils.GetStatusTextWithColor(s))
+ }
+ }
+
+ envName := "-"
+ if bp.EnvName != "" {
+ envName = bp.EnvName
+ }
+
+ data = append(data, []string{bp.ProjectName, envName, status, bp.ProjectId})
+ }
+
+ err = utils.PrintTable([]string{"Project Name", "Environment", "Status", "Project ID"}, data)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("\nTotal: %d blueprint(s)", len(blueprints)))
+ },
+}
+
+func init() {
+ rdeBlueprintCmd.AddCommand(rdeBlueprintListCmd)
+ rdeBlueprintListCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ rdeBlueprintListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/rde_blueprint_register.go b/cmd/rde_blueprint_register.go
new file mode 100644
index 00000000..4ca9d834
--- /dev/null
+++ b/cmd/rde_blueprint_register.go
@@ -0,0 +1,104 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeBlueprintRegisterCmd = &cobra.Command{
+ Use: "register",
+ Short: "Register a project as an RDE blueprint",
+ Long: `Register an existing project as an RDE blueprint by setting the
+BLUEPRINT_PROJECT_ID project-level variable and BLUEPRINT_KEY on the
+first DEVELOPMENT environment.
+
+The project must already exist and contain at least one environment.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ // Find the project by name
+ project, err := rdeFindProjectByName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("project %s not found in organization", rdeBlueprintProjectName))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ // Check if already registered as a blueprint
+ vars, err := utils.ListProjectVariables(client, project.Id)
+ if err == nil {
+ existing := utils.FindEnvironmentVariableByKey(rdeBlueprintProjectIdVar, vars)
+ if existing != nil {
+ utils.PrintlnInfo(fmt.Sprintf("Project %s is already registered as a blueprint", rdeBlueprintProjectName))
+ return
+ }
+ }
+
+ // Step 1: Create project-level env var BLUEPRINT_PROJECT_ID = projectId
+ utils.Println(fmt.Sprintf("Step 1/2: Setting %s on project %s...", rdeBlueprintProjectIdVar, rdeBlueprintProjectName))
+ err = utils.CreateProjectVariable(client, project.Id, rdeBlueprintProjectIdVar, project.Id, false)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to create project variable %s: %w", rdeBlueprintProjectIdVar, err))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ // Step 2: Find first environment and set BLUEPRINT_KEY = projectId
+ utils.Println("Step 2/2: Setting BLUEPRINT_KEY on environment...")
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), project.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to list environments: %w", err))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ envResults := environments.GetResults()
+ if len(envResults) == 0 {
+ utils.PrintlnError(fmt.Errorf("project %s has no environments - create at least one environment first", rdeBlueprintProjectName))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ // Prefer the first DEVELOPMENT environment, fallback to first env
+ var targetEnv *qovery.Environment
+ for _, env := range envResults {
+ if env.Mode == qovery.ENVIRONMENTMODEENUM_DEVELOPMENT {
+ targetEnv = &env
+ break
+ }
+ }
+ if targetEnv == nil {
+ targetEnv = &envResults[0]
+ }
+
+ err = utils.CreateEnvironmentVariable(client, project.Id, targetEnv.Id, rdeBlueprintKeyVar, project.Id, false)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to create environment variable %s: %w", rdeBlueprintKeyVar, err))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println("")
+ utils.Println("Blueprint registered successfully!")
+ utils.Println(fmt.Sprintf(" Project: %s (%s)", project.Name, project.Id))
+ utils.Println(fmt.Sprintf(" Environment: %s (%s)", targetEnv.Name, targetEnv.Id))
+ utils.Println(fmt.Sprintf(" Console: https://console.qovery.com/organization/%s/project/%s/environment/%s", orgId, project.Id, targetEnv.Id))
+ },
+}
+
+func init() {
+ rdeBlueprintCmd.AddCommand(rdeBlueprintRegisterCmd)
+ rdeBlueprintRegisterCmd.Flags().StringVarP(&rdeBlueprintProjectName, "project", "p", "", "Project Name to register as a blueprint")
+ rdeBlueprintRegisterCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+
+ _ = rdeBlueprintRegisterCmd.MarkFlagRequired("project")
+}
diff --git a/cmd/rde_blueprint_status.go b/cmd/rde_blueprint_status.go
new file mode 100644
index 00000000..7b08e642
--- /dev/null
+++ b/cmd/rde_blueprint_status.go
@@ -0,0 +1,69 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var rdeBlueprintStatusCmd = &cobra.Command{
+ Use: "status",
+ Short: "Show detailed status of a blueprint",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ rows := [][]string{
+ {"Blueprint", pterm.FgBlue.Sprintf("%s", bp.ProjectName)},
+ {"Project", bp.ProjectId},
+ }
+
+ if bp.EnvId == "" {
+ rows = append(rows, []string{"Environment", "(none)"})
+ rdePrintKeyValueTable(rows)
+ return
+ }
+
+ rows = append(rows, []string{"Environment", fmt.Sprintf("%s (%s)", bp.EnvName, bp.EnvId)})
+
+ status, err := rdeGetEnvStatus(client, bp.EnvId)
+ if err == nil {
+ rows = append(rows, []string{"Status", utils.GetStatusTextWithColor(status)})
+ }
+
+ rows = append(rows, []string{"Console", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s", orgId, bp.ProjectId, bp.EnvId)})
+
+ // Count children
+ children, err := rdeListChildren(client, orgId, bp.ProjectId)
+ if err == nil {
+ rows = append(rows, []string{"Children", fmt.Sprintf("%d RDE(s)", len(children))})
+ }
+
+ rdePrintKeyValueTable(rows)
+
+ // List services
+ utils.Println("")
+ rdePrintEnvServices(client, bp.EnvId)
+ },
+}
+
+func init() {
+ rdeBlueprintCmd.AddCommand(rdeBlueprintStatusCmd)
+ rdeBlueprintStatusCmd.Flags().StringVarP(&rdeBlueprintProjectName, "project", "p", "", "Blueprint Project Name")
+ rdeBlueprintStatusCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+
+ _ = rdeBlueprintStatusCmd.MarkFlagRequired("project")
+}
diff --git a/cmd/rde_blueprint_stop.go b/cmd/rde_blueprint_stop.go
new file mode 100644
index 00000000..68e20d06
--- /dev/null
+++ b/cmd/rde_blueprint_stop.go
@@ -0,0 +1,61 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeBlueprintStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop a blueprint environment",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if bp.EnvId == "" {
+ utils.PrintlnError(fmt.Errorf("blueprint %s has no environment with %s set", bp.ProjectName, rdeBlueprintKeyVar))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ _, _, err = client.EnvironmentActionsAPI.StopEnvironment(context.Background(), bp.EnvId).Execute()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("stop failed: %w", err))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("Request to stop blueprint %s has been queued..", pterm.FgBlue.Sprintf("%s", bp.ProjectName)))
+
+ if watchFlag {
+ time.Sleep(3 * time.Second)
+ utils.WatchEnvironment(bp.EnvId, qovery.STATEENUM_STOPPED, client)
+ }
+ },
+}
+
+func init() {
+ rdeBlueprintCmd.AddCommand(rdeBlueprintStopCmd)
+ rdeBlueprintStopCmd.Flags().StringVarP(&rdeBlueprintProjectName, "project", "p", "", "Blueprint Project Name")
+ rdeBlueprintStopCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ rdeBlueprintStopCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch stop status until it completes or an error occurs")
+
+ _ = rdeBlueprintStopCmd.MarkFlagRequired("project")
+}
diff --git a/cmd/rde_blueprint_unregister.go b/cmd/rde_blueprint_unregister.go
new file mode 100644
index 00000000..56133890
--- /dev/null
+++ b/cmd/rde_blueprint_unregister.go
@@ -0,0 +1,69 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var rdeBlueprintUnregisterCmd = &cobra.Command{
+ Use: "unregister",
+ Short: "Unregister a project as an RDE blueprint",
+ Long: `Remove the BLUEPRINT_PROJECT_ID and BLUEPRINT_KEY environment variables
+from the project and its environment. This does NOT delete the project itself.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ // Delete project-level BLUEPRINT_PROJECT_ID var
+ utils.Println(fmt.Sprintf("Removing %s from project %s...", rdeBlueprintProjectIdVar, bp.ProjectName))
+ projectVars, err := utils.ListProjectVariables(client, bp.ProjectId)
+ if err == nil {
+ bpVar := utils.FindEnvironmentVariableByKey(rdeBlueprintProjectIdVar, projectVars)
+ if bpVar != nil {
+ _, err = client.VariableMainCallsAPI.DeleteVariable(ctx(), bpVar.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to delete %s: %w", rdeBlueprintProjectIdVar, err))
+ }
+ }
+ }
+
+ // Delete environment-level BLUEPRINT_KEY var
+ if bp.EnvId != "" {
+ utils.Println(fmt.Sprintf("Removing %s from environment %s...", rdeBlueprintKeyVar, bp.EnvName))
+ envVars, err := utils.ListEnvironmentVariables(client, bp.EnvId)
+ if err == nil {
+ bkVar := utils.FindEnvironmentVariableByKey(rdeBlueprintKeyVar, envVars)
+ if bkVar != nil {
+ _, err = client.VariableMainCallsAPI.DeleteVariable(ctx(), bkVar.Id).Execute()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to delete %s: %w", rdeBlueprintKeyVar, err))
+ }
+ }
+ }
+ }
+
+ utils.Println("")
+ utils.Println(fmt.Sprintf("Blueprint %s unregistered. Project and environments are preserved.", bp.ProjectName))
+ },
+}
+
+func init() {
+ rdeBlueprintCmd.AddCommand(rdeBlueprintUnregisterCmd)
+ rdeBlueprintUnregisterCmd.Flags().StringVarP(&rdeBlueprintProjectName, "project", "p", "", "Blueprint Project Name to unregister")
+ rdeBlueprintUnregisterCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+
+ _ = rdeBlueprintUnregisterCmd.MarkFlagRequired("project")
+}
diff --git a/cmd/rde_create.go b/cmd/rde_create.go
new file mode 100644
index 00000000..9e37a6a6
--- /dev/null
+++ b/cmd/rde_create.go
@@ -0,0 +1,357 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Provision a new Remote Development Environment from a blueprint",
+ Long: `Create a new RDE by cloning a blueprint environment into a new project.
+
+This command:
+ 1. Creates a new project for the RDE
+ 2. Creates an RBAC role with scoped permissions (unless --skip-rbac)
+ 3. Clones the blueprint environment into the new project
+ 4. Updates the TTL job to target the new environment (if present)
+ 5. Invites the developer via email (unless --skip-invite)
+ 6. Triggers deployment (unless --skip-deploy)`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ // Validate required flags
+ if rdeBlueprintProjectName == "" {
+ utils.PrintlnError(fmt.Errorf("--blueprint is required"))
+ os.Exit(1)
+ panic("unreachable")
+ }
+ if rdeName == "" {
+ utils.PrintlnError(fmt.Errorf("--name is required"))
+ os.Exit(1)
+ panic("unreachable")
+ }
+ if rdeEmail == "" && !rdeSkipInvite {
+ utils.PrintlnInfo("No --email provided, skipping member invitation (use --skip-invite to suppress this message)")
+ rdeSkipInvite = true
+ }
+
+ // Step 1: Resolve blueprint
+ utils.Println(fmt.Sprintf("Resolving blueprint %s...", pterm.FgBlue.Sprintf("%s", rdeBlueprintProjectName)))
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+ if bp.EnvId == "" {
+ utils.PrintlnError(fmt.Errorf("blueprint %s has no environment with %s set", bp.ProjectName, rdeBlueprintKeyVar))
+ os.Exit(1)
+ panic("unreachable")
+ }
+ utils.Println(fmt.Sprintf(" Blueprint: %s (env: %s)", bp.ProjectName, bp.EnvId))
+
+ // Step 2: Create project
+ projectName := fmt.Sprintf("rde-%s", rdeName)
+ utils.Println(fmt.Sprintf("\nStep 1/6: Creating project %s...", pterm.FgBlue.Sprintf("%s", projectName)))
+ desc := fmt.Sprintf("RDE for %s (blueprint: %s)", rdeName, bp.ProjectName)
+ projectReq := qovery.NewProjectRequest(projectName)
+ projectReq.Description = &desc
+ project, _, err := client.ProjectsAPI.CreateProject(ctx(), orgId).ProjectRequest(*projectReq).Execute()
+ if err != nil {
+ // Check if project already exists
+ existing, findErr := rdeFindProjectByName(client, orgId, projectName)
+ if findErr == nil && existing != nil {
+ utils.PrintlnInfo(fmt.Sprintf("Project %s already exists, reusing...", projectName))
+ project = existing
+ } else {
+ utils.PrintlnError(fmt.Errorf("failed to create project: %w", err))
+ os.Exit(1)
+ panic("unreachable")
+ }
+ }
+ utils.Println(fmt.Sprintf(" Project: %s", project.Id))
+
+ // Step 3: Create RBAC role
+ var roleId string
+ if !rdeSkipRbac {
+ roleName := fmt.Sprintf("RDE-%s", rdeName)
+ utils.Println(fmt.Sprintf("\nStep 2/6: Creating RBAC role %s...", pterm.FgBlue.Sprintf("%s", roleName)))
+
+ roleReq := qovery.NewOrganizationCustomRoleCreateRequest(roleName)
+ roleDesc := fmt.Sprintf("Access to %s only", projectName)
+ roleReq.Description = &roleDesc
+ role, _, err := client.OrganizationCustomRoleAPI.CreateOrganizationCustomRole(ctx(), orgId).
+ OrganizationCustomRoleCreateRequest(*roleReq).Execute()
+ if err != nil {
+ // Check if role already exists
+ existingRole, _ := rdeFindCustomRoleByName(client, orgId, roleName)
+ if existingRole != nil && existingRole.Id != nil {
+ utils.PrintlnInfo(fmt.Sprintf("Role %s already exists, reusing...", roleName))
+ roleId = *existingRole.Id
+ } else {
+ utils.PrintlnError(fmt.Errorf("failed to create RBAC role: %w", err))
+ utils.PrintlnInfo("Continuing without RBAC role (use --skip-rbac to suppress)")
+ }
+ } else if role.Id != nil {
+ roleId = *role.Id
+ }
+
+ if roleId != "" {
+ // Set permissions: all clusters VIEWER except target = ENV_CREATOR, all projects NO_ACCESS except ours = DEPLOYER
+ err = rdeSetRolePermissions(client, orgId, roleId, roleName, project.Id)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to set role permissions: %w", err))
+ utils.PrintlnInfo("RBAC role created but permissions may be incomplete")
+ }
+ utils.Println(fmt.Sprintf(" Role: %s", roleId))
+ }
+ } else {
+ utils.Println("\nStep 2/6: Skipping RBAC role creation (--skip-rbac)")
+ }
+
+ // Step 4: Clone blueprint
+ utils.Println("\nStep 3/6: Cloning blueprint environment...")
+ cloneReq := qovery.CloneEnvironmentRequest{
+ Name: "workspace",
+ ProjectId: &project.Id,
+ Mode: qovery.ENVIRONMENTMODEENUM_DEVELOPMENT.Ptr(),
+ }
+
+ // Default to the blueprint's cluster
+ blueprintEnv, _, bpEnvErr := client.EnvironmentMainCallsAPI.GetEnvironment(ctx(), bp.EnvId).Execute()
+ if bpEnvErr == nil {
+ bpClusterId := blueprintEnv.ClusterId
+ cloneReq.ClusterId = &bpClusterId
+ clusterDisplay := bpClusterId
+ if blueprintEnv.ClusterName != nil {
+ clusterDisplay = *blueprintEnv.ClusterName
+ }
+ utils.Println(fmt.Sprintf(" Using blueprint cluster: %s", pterm.FgBlue.Sprintf("%s", clusterDisplay)))
+ }
+
+ // Override with --cluster flag if provided
+ if clusterName != "" {
+ clusters, _, clErr := client.ClustersAPI.ListOrganizationCluster(ctx(), orgId).Execute()
+ if clErr == nil {
+ found := false
+ for _, c := range clusters.GetResults() {
+ if strings.EqualFold(c.Name, clusterName) {
+ clId := c.Id
+ cloneReq.ClusterId = &clId
+ utils.Println(fmt.Sprintf(" Overriding cluster to: %s", pterm.FgBlue.Sprintf("%s", clusterName)))
+ found = true
+ break
+ }
+ }
+ if !found {
+ utils.PrintlnError(fmt.Errorf("cluster %s not found", clusterName))
+ os.Exit(1)
+ panic("unreachable")
+ }
+ }
+ }
+
+ clonedEnv, _, err := client.EnvironmentActionsAPI.CloneEnvironment(ctx(), bp.EnvId).
+ CloneEnvironmentRequest(cloneReq).Execute()
+ if err != nil {
+ // Check if environment already exists
+ envInfo, findErr := rdeFindBlueprintEnv(client, project.Id)
+ if findErr == nil && envInfo != nil {
+ utils.PrintlnInfo("Environment already exists, reusing...")
+ // Create a minimal environment struct for use below
+ clonedEnv = &qovery.Environment{}
+ clonedEnv.Id = envInfo.EnvId
+ clonedEnv.Name = envInfo.EnvName
+ } else {
+ utils.PrintlnError(fmt.Errorf("failed to clone blueprint: %w", err))
+ os.Exit(1)
+ panic("unreachable")
+ }
+ }
+ utils.Println(fmt.Sprintf(" Environment: %s", clonedEnv.Id))
+
+ // Set RDE_OWNER_EMAIL on the cloned environment if email was provided
+ if rdeEmail != "" {
+ _ = utils.CreateEnvironmentVariable(client, project.Id, clonedEnv.Id, rdeOwnerEmailVar, rdeEmail, false)
+ }
+
+ // Step 5: Update TTL job (if present)
+ utils.Println("\nStep 4/6: Checking for TTL job...")
+ rdeUpdateTTLJob(client, clonedEnv.Id)
+
+ // Step 6: Invite member
+ if !rdeSkipInvite && rdeEmail != "" {
+ utils.Println(fmt.Sprintf("\nStep 5/6: Inviting %s...", rdeEmail))
+ inviteReq := qovery.NewInviteMemberRequest(rdeEmail)
+ if roleId != "" {
+ inviteReq.RoleId = &roleId
+ }
+ _, _, err = client.MembersAPI.PostInviteMember(ctx(), orgId).
+ InviteMemberRequest(*inviteReq).Execute()
+ if err != nil {
+ utils.PrintlnInfo(fmt.Sprintf("Invitation failed or already sent: %v", err))
+ } else {
+ utils.Println(fmt.Sprintf(" Invited: %s", rdeEmail))
+ }
+ } else {
+ utils.Println("\nStep 5/6: Skipping invitation")
+ }
+
+ // Step 7: Deploy
+ if !rdeSkipDeploy {
+ utils.Println("\nStep 6/6: Deploying...")
+ _, _, err = client.EnvironmentActionsAPI.DeployEnvironment(ctx(), clonedEnv.Id).Execute()
+ if err != nil {
+ utils.PrintlnInfo(fmt.Sprintf("Deploy failed: %v (deploy from Console)", err))
+ } else {
+ utils.Println(" Deployment triggered")
+ }
+ } else {
+ utils.Println("\nStep 6/6: Skipping deployment (--skip-deploy)")
+ }
+
+ utils.Println("")
+ utils.Println(fmt.Sprintf("RDE %s provisioned successfully!", pterm.FgBlue.Sprintf("%s", rdeName)))
+ utils.Println("")
+ rdePrintKeyValueTable([][]string{
+ {"Project", project.Id},
+ {"Environment", clonedEnv.Id},
+ {"Console", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s", orgId, project.Id, clonedEnv.Id)},
+ })
+ utils.Println("")
+ utils.PrintlnInfo("Workspace URL will be available once deployment completes.")
+ },
+}
+
+// rdeSetRolePermissions configures the RBAC role with appropriate cluster and project permissions.
+func rdeSetRolePermissions(client *qovery.APIClient, orgId string, roleId string, roleName string, targetProjectId string) error {
+ // Get all clusters
+ clusters, _, err := client.ClustersAPI.ListOrganizationCluster(ctx(), orgId).Execute()
+ if err != nil {
+ return fmt.Errorf("failed to list clusters: %w", err)
+ }
+
+ var clusterPerms []qovery.OrganizationCustomRoleUpdateRequestClusterPermissionsInner
+ for _, c := range clusters.GetResults() {
+ perm := qovery.ORGANIZATIONCUSTOMROLECLUSTERPERMISSION_VIEWER
+ // If cluster name matches, give ENV_CREATOR
+ if clusterName != "" && strings.EqualFold(c.Name, clusterName) {
+ perm = qovery.ORGANIZATIONCUSTOMROLECLUSTERPERMISSION_ENV_CREATOR
+ } else if clusterName == "" {
+ // If no cluster specified, give ENV_CREATOR on all clusters
+ perm = qovery.ORGANIZATIONCUSTOMROLECLUSTERPERMISSION_ENV_CREATOR
+ }
+ cId := c.Id
+ clusterPerms = append(clusterPerms, qovery.OrganizationCustomRoleUpdateRequestClusterPermissionsInner{
+ ClusterId: &cId,
+ Permission: &perm,
+ })
+ }
+
+ // Get all projects
+ projects, _, err := client.ProjectsAPI.ListProject(ctx(), orgId).Execute()
+ if err != nil {
+ return fmt.Errorf("failed to list projects: %w", err)
+ }
+
+ var projectPerms []qovery.OrganizationCustomRoleUpdateRequestProjectPermissionsInner
+ for _, p := range projects.GetResults() {
+ isAdmin := false
+ pId := p.Id
+
+ var permissions []qovery.OrganizationCustomRoleUpdateRequestProjectPermissionsInnerPermissionsInner
+ if p.Id == targetProjectId {
+ // Target project: DEPLOYER for DEVELOPMENT and PREVIEW, VIEWER for STAGING, NO_ACCESS for PRODUCTION
+ devMode := qovery.ENVIRONMENTMODEENUM_DEVELOPMENT
+ stagingMode := qovery.ENVIRONMENTMODEENUM_STAGING
+ prodMode := qovery.ENVIRONMENTMODEENUM_PRODUCTION
+ previewMode := qovery.ENVIRONMENTMODEENUM_PREVIEW
+ deployerPerm := qovery.ORGANIZATIONCUSTOMROLEPROJECTPERMISSION_DEPLOYER
+ viewerPerm := qovery.ORGANIZATIONCUSTOMROLEPROJECTPERMISSION_VIEWER
+ noAccessPerm := qovery.ORGANIZATIONCUSTOMROLEPROJECTPERMISSION_NO_ACCESS
+
+ permissions = []qovery.OrganizationCustomRoleUpdateRequestProjectPermissionsInnerPermissionsInner{
+ {EnvironmentType: &devMode, Permission: &deployerPerm},
+ {EnvironmentType: &stagingMode, Permission: &viewerPerm},
+ {EnvironmentType: &prodMode, Permission: &noAccessPerm},
+ {EnvironmentType: &previewMode, Permission: &deployerPerm},
+ }
+ } else {
+ // Other projects: NO_ACCESS for all
+ devMode := qovery.ENVIRONMENTMODEENUM_DEVELOPMENT
+ stagingMode := qovery.ENVIRONMENTMODEENUM_STAGING
+ prodMode := qovery.ENVIRONMENTMODEENUM_PRODUCTION
+ previewMode := qovery.ENVIRONMENTMODEENUM_PREVIEW
+ noAccessPerm := qovery.ORGANIZATIONCUSTOMROLEPROJECTPERMISSION_NO_ACCESS
+
+ permissions = []qovery.OrganizationCustomRoleUpdateRequestProjectPermissionsInnerPermissionsInner{
+ {EnvironmentType: &devMode, Permission: &noAccessPerm},
+ {EnvironmentType: &stagingMode, Permission: &noAccessPerm},
+ {EnvironmentType: &prodMode, Permission: &noAccessPerm},
+ {EnvironmentType: &previewMode, Permission: &noAccessPerm},
+ }
+ }
+
+ projectPerms = append(projectPerms, qovery.OrganizationCustomRoleUpdateRequestProjectPermissionsInner{
+ ProjectId: &pId,
+ IsAdmin: &isAdmin,
+ Permissions: permissions,
+ })
+ }
+
+ updateReq := qovery.NewOrganizationCustomRoleUpdateRequest(roleName, clusterPerms, projectPerms)
+ _, _, err = client.OrganizationCustomRoleAPI.EditOrganizationCustomRole(ctx(), orgId, roleId).
+ OrganizationCustomRoleUpdateRequest(*updateReq).Execute()
+ return err
+}
+
+// rdeUpdateTTLJob finds and updates the ttl-auto-shutdown job in the environment.
+func rdeUpdateTTLJob(client *qovery.APIClient, envId string) {
+ jobs, _, err := client.JobsAPI.ListJobs(ctx(), envId).Execute()
+ if err != nil {
+ utils.Println(" No jobs found (non-critical)")
+ return
+ }
+
+ for _, job := range jobs.GetResults() {
+ jobName := utils.GetJobName(&job)
+ if jobName == "ttl-auto-shutdown" {
+ jobId := utils.GetJobId(&job)
+ utils.Println(fmt.Sprintf(" Found TTL job: %s", jobId))
+ // The TTL job is cloned from the blueprint and may reference the blueprint env ID
+ // in its arguments. We don't modify the curl command here since the token would
+ // also need updating. The TTL job will work as-is if the SHUTDOWN_TOKEN env var
+ // is properly set on the job.
+ utils.Println(" TTL job preserved from blueprint clone")
+ return
+ }
+ }
+
+ utils.Println(" No TTL job found (non-critical)")
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeCreateCmd)
+ rdeCreateCmd.Flags().StringVarP(&rdeBlueprintProjectName, "blueprint", "b", "", "Blueprint Project Name to clone from")
+ rdeCreateCmd.Flags().StringVarP(&rdeName, "name", "n", "", "Name for the new RDE (will create project rde-)")
+ rdeCreateCmd.Flags().StringVarP(&rdeEmail, "email", "e", "", "Email address to invite the developer")
+ rdeCreateCmd.Flags().StringVarP(&clusterName, "cluster", "c", "", "Cluster Name where to create the RDE")
+ rdeCreateCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ rdeCreateCmd.Flags().BoolVarP(&rdeSkipRbac, "skip-rbac", "", false, "Skip RBAC role creation")
+ rdeCreateCmd.Flags().BoolVarP(&rdeSkipInvite, "skip-invite", "", false, "Skip member invitation")
+ rdeCreateCmd.Flags().BoolVarP(&rdeSkipDeploy, "skip-deploy", "", false, "Skip deployment after cloning")
+
+ _ = rdeCreateCmd.MarkFlagRequired("blueprint")
+ _ = rdeCreateCmd.MarkFlagRequired("name")
+}
diff --git a/cmd/rde_delete.go b/cmd/rde_delete.go
new file mode 100644
index 00000000..1e2ccafb
--- /dev/null
+++ b/cmd/rde_delete.go
@@ -0,0 +1,111 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete an RDE (environment, project, RBAC role, and API token)",
+ Long: `Fully remove an RDE by:
+ 1. Stopping the environment (if running)
+ 2. Deleting the environment
+ 3. Deleting the project
+ 4. Deleting the RBAC role RDE- (if exists)
+ 5. Deleting the API token ttl- (if exists)`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ projectName := fmt.Sprintf("rde-%s", rdeName)
+ utils.Println(fmt.Sprintf("Deleting RDE %s...", pterm.FgBlue.Sprintf("%s", rdeName)))
+
+ // Find the project
+ project, err := rdeFindProjectByName(client, orgId, projectName)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("RDE %s not found (no project %s)", rdeName, projectName))
+ // Still try to clean up role and token
+ rdeCleanupRoleAndToken(client, orgId, rdeName)
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ // Find environment
+ environments, _, err := client.EnvironmentsAPI.ListEnvironment(ctx(), project.Id).Execute()
+ if err == nil {
+ for _, env := range environments.GetResults() {
+ // Stop environment
+ status, _ := rdeGetEnvStatus(client, env.Id)
+ if status != qovery.STATEENUM_STOPPED && status != "" {
+ utils.Println(fmt.Sprintf(" Stopping environment %s...", pterm.FgBlue.Sprintf("%s", env.Name)))
+ _, _, _ = client.EnvironmentActionsAPI.StopEnvironment(ctx(), env.Id).Execute()
+ time.Sleep(2 * time.Second)
+ }
+
+ // Delete environment
+ utils.Println(fmt.Sprintf(" Deleting environment %s...", pterm.FgBlue.Sprintf("%s", env.Name)))
+ _, err = client.EnvironmentMainCallsAPI.DeleteEnvironment(ctx(), env.Id).Execute()
+ if err != nil {
+ utils.PrintlnInfo(fmt.Sprintf("Failed to delete environment: %v", err))
+ }
+ }
+ }
+
+ // Delete project
+ utils.Println(fmt.Sprintf(" Deleting project %s...", pterm.FgBlue.Sprintf("%s", projectName)))
+ _, err = client.ProjectMainCallsAPI.DeleteProject(ctx(), project.Id).Execute()
+ if err != nil {
+ utils.PrintlnInfo(fmt.Sprintf("Failed to delete project: %v", err))
+ }
+
+ // Cleanup role and token
+ rdeCleanupRoleAndToken(client, orgId, rdeName)
+
+ utils.Println(fmt.Sprintf("\nRDE %s fully removed.", pterm.FgBlue.Sprintf("%s", rdeName)))
+ },
+}
+
+// rdeCleanupRoleAndToken removes the RBAC role and API token associated with an RDE.
+func rdeCleanupRoleAndToken(client *qovery.APIClient, orgId string, name string) {
+ // Delete RBAC role
+ roleName := fmt.Sprintf("RDE-%s", name)
+ role, _ := rdeFindCustomRoleByName(client, orgId, roleName)
+ if role != nil && role.Id != nil {
+ utils.Println(fmt.Sprintf(" Deleting role %s...", pterm.FgBlue.Sprintf("%s", roleName)))
+ _, _ = client.OrganizationCustomRoleAPI.DeleteOrganizationCustomRole(ctx(), orgId, *role.Id).Execute()
+ }
+
+ // Delete API token
+ tokenName := fmt.Sprintf("ttl-%s", name)
+ tokens, _, err := client.OrganizationApiTokenAPI.ListOrganizationApiTokens(ctx(), orgId).Execute()
+ if err == nil {
+ for _, token := range tokens.GetResults() {
+ if token.Name != nil && *token.Name == tokenName {
+ if token.Id != "" {
+ utils.Println(fmt.Sprintf(" Deleting API token %s...", pterm.FgBlue.Sprintf("%s", tokenName)))
+ _, _ = client.OrganizationApiTokenAPI.DeleteOrganizationApiToken(ctx(), orgId, token.Id).Execute()
+ }
+ break
+ }
+ }
+ }
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeDeleteCmd)
+ rdeDeleteCmd.Flags().StringVarP(&rdeName, "name", "n", "", "RDE Name")
+ rdeDeleteCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ rdeDeleteCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch deletion status")
+
+ _ = rdeDeleteCmd.MarkFlagRequired("name")
+}
diff --git a/cmd/rde_delete_all.go b/cmd/rde_delete_all.go
new file mode 100644
index 00000000..41e53977
--- /dev/null
+++ b/cmd/rde_delete_all.go
@@ -0,0 +1,97 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeDeleteAllCmd = &cobra.Command{
+ Use: "delete-all",
+ Short: "Delete ALL RDE environments",
+ Long: `Delete all RDE environments permanently. Requires --confirm flag.
+
+This will delete the environment, project, RBAC role, and API token for each RDE.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if !rdeConfirmFlag {
+ utils.PrintlnError(fmt.Errorf("this will delete ALL RDE environments permanently"))
+ utils.Println("Run with --confirm to proceed: qovery rde delete-all --confirm")
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ var children []rdeChildInfo
+
+ if rdeBlueprintProjectName != "" {
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+ children, err = rdeListChildren(client, orgId, bp.ProjectId)
+ checkError(err)
+ } else {
+ children, err = rdeListAllChildren(client, orgId)
+ checkError(err)
+ }
+
+ if len(children) == 0 {
+ utils.Println("No RDE instances found.")
+ return
+ }
+
+ utils.Println(fmt.Sprintf("Deleting %d RDE environment(s)...", len(children)))
+ utils.Println("")
+
+ for _, child := range children {
+ // Extract the RDE name from project name (strip "rde-" prefix if present)
+ name := strings.TrimPrefix(child.ProjectName, "rde-")
+
+ utils.Println(fmt.Sprintf("=== Deleting: %s ===", pterm.FgBlue.Sprintf("%s", child.ProjectName)))
+
+ // Stop environment
+ if child.EnvId != "" {
+ status, _ := rdeGetEnvStatus(client, child.EnvId)
+ if status != qovery.STATEENUM_STOPPED && status != "" {
+ utils.Println(" Stopping environment...")
+ _, _, _ = client.EnvironmentActionsAPI.StopEnvironment(ctx(), child.EnvId).Execute()
+ time.Sleep(2 * time.Second)
+ }
+
+ utils.Println(" Deleting environment...")
+ _, _ = client.EnvironmentMainCallsAPI.DeleteEnvironment(ctx(), child.EnvId).Execute()
+ }
+
+ // Delete project
+ utils.Println(fmt.Sprintf(" Deleting project %s...", child.ProjectName))
+ _, _ = client.ProjectMainCallsAPI.DeleteProject(ctx(), child.ProjectId).Execute()
+
+ // Cleanup role and token
+ rdeCleanupRoleAndToken(client, orgId, name)
+
+ utils.Println("")
+ }
+
+ utils.Println("All RDE environments deleted.")
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeDeleteAllCmd)
+ rdeDeleteAllCmd.Flags().BoolVarP(&rdeConfirmFlag, "confirm", "", false, "Confirm deletion of all RDE environments")
+ rdeDeleteAllCmd.Flags().StringVarP(&rdeBlueprintProjectName, "blueprint", "b", "", "Filter by Blueprint Project Name")
+ rdeDeleteAllCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+}
diff --git a/cmd/rde_info.go b/cmd/rde_info.go
new file mode 100644
index 00000000..c8882ea1
--- /dev/null
+++ b/cmd/rde_info.go
@@ -0,0 +1,89 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeInfoCmd = &cobra.Command{
+ Use: "info",
+ Short: "Show RDE platform overview",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ // Get organization name
+ orgName := orgId
+ orgs, _, err := client.OrganizationMainCallsAPI.ListOrganization(ctx()).Execute()
+ if err == nil {
+ for _, org := range orgs.GetResults() {
+ if org.Id == orgId {
+ orgName = org.Name
+ break
+ }
+ }
+ }
+
+ // List blueprints
+ blueprints, _ := rdeListBlueprintProjects(client, orgId)
+
+ // List all children and count statuses
+ allChildren, _ := rdeListAllChildren(client, orgId)
+ running := 0
+ stopped := 0
+ errors := 0
+
+ for _, child := range allChildren {
+ if child.EnvId != "" {
+ status, err := rdeGetEnvStatus(client, child.EnvId)
+ if err != nil {
+ errors++
+ continue
+ }
+ switch status {
+ case qovery.STATEENUM_DEPLOYED, qovery.STATEENUM_RESTARTED:
+ running++
+ case qovery.STATEENUM_STOPPED:
+ stopped++
+ default:
+ errors++
+ }
+ }
+ }
+
+ // Platform summary table
+ rdePrintKeyValueTable([][]string{
+ {"Organization", fmt.Sprintf("%s (%s)", orgName, orgId)},
+ {"Blueprints", fmt.Sprintf("%d", len(blueprints))},
+ {"RDEs", fmt.Sprintf("%d total (%d running, %d stopped, %d error/other)", len(allChildren), running, stopped, errors)},
+ })
+
+ // Blueprints detail table
+ if len(blueprints) > 0 {
+ utils.Println("")
+ var data [][]string
+ for _, bp := range blueprints {
+ status := "NO_ENV"
+ if bp.EnvId != "" {
+ s, err := rdeGetEnvStatus(client, bp.EnvId)
+ if err == nil {
+ status = utils.GetStatusTextWithColor(s)
+ }
+ }
+ data = append(data, []string{bp.ProjectName, bp.EnvName, status, bp.ProjectId})
+ }
+ _ = utils.PrintTable([]string{"Blueprint", "Environment", "Status", "Project ID"}, data)
+ }
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeInfoCmd)
+ rdeInfoCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+}
diff --git a/cmd/rde_list.go b/cmd/rde_list.go
new file mode 100644
index 00000000..ee312b26
--- /dev/null
+++ b/cmd/rde_list.go
@@ -0,0 +1,139 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List all RDE instances",
+ Long: `List all Remote Development Environments, optionally filtered by blueprint.
+
+Shows name, blueprint, status, uptime, and workspace URL for each RDE.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ var children []rdeChildInfo
+
+ if rdeBlueprintProjectName != "" {
+ // Filter by specific blueprint
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+ children, err = rdeListChildren(client, orgId, bp.ProjectId)
+ checkError(err)
+ } else {
+ // List all children across all blueprints
+ children, err = rdeListAllChildren(client, orgId)
+ checkError(err)
+ }
+
+ if len(children) == 0 {
+ utils.Println("No RDE instances found.")
+ return
+ }
+
+ if jsonFlag {
+ var results []interface{}
+ for _, child := range children {
+ status := ""
+ url := ""
+ if child.EnvId != "" {
+ s, err := rdeGetEnvStatus(client, child.EnvId)
+ if err == nil {
+ status = string(s)
+ }
+ if s == qovery.STATEENUM_DEPLOYED {
+ url = rdeGetWorkspaceUrl(client, child.EnvId)
+ }
+ }
+ bpName := rdeBlueprintNameForProjectId(client, child.BlueprintProjectId)
+ results = append(results, map[string]interface{}{
+ "project_id": child.ProjectId,
+ "project_name": child.ProjectName,
+ "env_id": child.EnvId,
+ "env_name": child.EnvName,
+ "blueprint_id": child.BlueprintProjectId,
+ "blueprint_name": bpName,
+ "status": status,
+ "owner": child.OwnerEmail,
+ "workspace_url": url,
+ })
+ }
+ j, _ := json.Marshal(results)
+ utils.Println(string(j))
+ return
+ }
+
+ running := 0
+ stopped := 0
+ errors := 0
+
+ var data [][]string
+ for _, child := range children {
+ status := "UNKNOWN"
+ uptime := "-"
+ url := "-"
+
+ if child.EnvId != "" {
+ s, err := rdeGetEnvStatus(client, child.EnvId)
+ if err == nil {
+ status = string(utils.GetStatusTextWithColor(s))
+ switch s {
+ case qovery.STATEENUM_DEPLOYED, qovery.STATEENUM_RESTARTED:
+ running++
+ url = rdeGetWorkspaceUrl(client, child.EnvId)
+ if url == "" {
+ url = "-"
+ }
+ lastDeploy := rdeGetLastDeployTime(client, child.EnvId)
+ uptime = rdeFormatUptime(lastDeploy)
+ case qovery.STATEENUM_STOPPED:
+ stopped++
+ default:
+ errors++
+ }
+ } else {
+ errors++
+ }
+ }
+
+ bpName := rdeBlueprintNameForProjectId(client, child.BlueprintProjectId)
+ owner := child.OwnerEmail
+ if owner == "" {
+ owner = "-"
+ }
+
+ data = append(data, []string{child.ProjectName, bpName, status, uptime, owner, url})
+ }
+
+ err = utils.PrintTable([]string{"Name", "Blueprint", "Status", "Uptime", "Owner", "Workspace URL"}, data)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ utils.Println(fmt.Sprintf("\nTotal: %d RDE(s) (%d running, %d stopped, %d error/other)", len(children), running, stopped, errors))
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeListCmd)
+ rdeListCmd.Flags().StringVarP(&rdeBlueprintProjectName, "blueprint", "b", "", "Filter by Blueprint Project Name")
+ rdeListCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ rdeListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/rde_logs.go b/cmd/rde_logs.go
new file mode 100644
index 00000000..7fcd984f
--- /dev/null
+++ b/cmd/rde_logs.go
@@ -0,0 +1,71 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var rdeLogsCmd = &cobra.Command{
+ Use: "logs",
+ Short: "Fetch recent logs from an RDE workspace",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ child, err := rdeFindChildByName(client, orgId, fmt.Sprintf("rde-%s", rdeName))
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ // Find the first application in the environment
+ apps, _, err := client.ApplicationsAPI.ListApplication(ctx(), child.EnvId).Execute()
+ if err != nil || len(apps.GetResults()) == 0 {
+ utils.PrintlnError(fmt.Errorf("no applications found in RDE %s", rdeName))
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ appId := apps.GetResults()[0].Id
+ appName := apps.GetResults()[0].GetName()
+
+ utils.Println(fmt.Sprintf("Fetching logs for RDE %s (service: %s)...", rdeName, appName))
+ utils.Println("")
+
+ logs, _, err := client.ApplicationLogsAPI.ListApplicationLog(ctx(), appId).Execute()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("failed to fetch logs: %w", err))
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ logResults := logs.GetResults()
+ // Show last 50 lines
+ start := 0
+ if len(logResults) > 50 {
+ start = len(logResults) - 50
+ }
+
+ for _, logEntry := range logResults[start:] {
+ msg := logEntry.GetMessage()
+ if msg != "" {
+ utils.Println(msg)
+ }
+ }
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeLogsCmd)
+ rdeLogsCmd.Flags().StringVarP(&rdeName, "name", "n", "", "RDE Name")
+ rdeLogsCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+
+ _ = rdeLogsCmd.MarkFlagRequired("name")
+}
diff --git a/cmd/rde_start.go b/cmd/rde_start.go
new file mode 100644
index 00000000..41d6f12d
--- /dev/null
+++ b/cmd/rde_start.go
@@ -0,0 +1,54 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeStartCmd = &cobra.Command{
+ Use: "start",
+ Short: "Start (deploy) an RDE",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ child, err := rdeFindChildByName(client, orgId, fmt.Sprintf("rde-%s", rdeName))
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ _, _, err = client.EnvironmentActionsAPI.DeployEnvironment(ctx(), child.EnvId).Execute()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("deploy failed: %w", err))
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ utils.Println(fmt.Sprintf("Request to start RDE %s has been queued..", pterm.FgBlue.Sprintf("%s", rdeName)))
+
+ if watchFlag {
+ time.Sleep(3 * time.Second)
+ utils.WatchEnvironment(child.EnvId, qovery.STATEENUM_DEPLOYED, client)
+ }
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeStartCmd)
+ rdeStartCmd.Flags().StringVarP(&rdeName, "name", "n", "", "RDE Name")
+ rdeStartCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ rdeStartCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch deployment status until it's ready or an error occurs")
+
+ _ = rdeStartCmd.MarkFlagRequired("name")
+}
diff --git a/cmd/rde_start_all.go b/cmd/rde_start_all.go
new file mode 100644
index 00000000..1ea690b9
--- /dev/null
+++ b/cmd/rde_start_all.go
@@ -0,0 +1,62 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var rdeStartAllCmd = &cobra.Command{
+ Use: "start-all",
+ Short: "Start (deploy) all RDE environments",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ var children []rdeChildInfo
+
+ if rdeBlueprintProjectName != "" {
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+ children, err = rdeListChildren(client, orgId, bp.ProjectId)
+ checkError(err)
+ } else {
+ children, err = rdeListAllChildren(client, orgId)
+ checkError(err)
+ }
+
+ if len(children) == 0 {
+ utils.Println("No RDE instances found.")
+ return
+ }
+
+ utils.Println(fmt.Sprintf("Starting %d RDE environment(s)...", len(children)))
+ for _, child := range children {
+ if child.EnvId != "" {
+ _, _, err := client.EnvironmentActionsAPI.DeployEnvironment(ctx(), child.EnvId).Execute()
+ if err != nil {
+ utils.Println(fmt.Sprintf(" Failed to start: %s (%v)", pterm.FgBlue.Sprintf("%s", child.ProjectName), err))
+ } else {
+ utils.Println(fmt.Sprintf(" Request to start %s has been queued..", pterm.FgBlue.Sprintf("%s", child.ProjectName)))
+ }
+ }
+ }
+ utils.Println("Done.")
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeStartAllCmd)
+ rdeStartAllCmd.Flags().StringVarP(&rdeBlueprintProjectName, "blueprint", "b", "", "Filter by Blueprint Project Name")
+ rdeStartAllCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+}
diff --git a/cmd/rde_status.go b/cmd/rde_status.go
new file mode 100644
index 00000000..9b6f3b16
--- /dev/null
+++ b/cmd/rde_status.go
@@ -0,0 +1,74 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeStatusCmd = &cobra.Command{
+ Use: "status",
+ Short: "Show detailed status of an RDE",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ child, err := rdeFindChildByName(client, orgId, fmt.Sprintf("rde-%s", rdeName))
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ bpName := rdeBlueprintNameForProjectId(client, child.BlueprintProjectId)
+
+ rows := [][]string{
+ {"RDE", pterm.FgBlue.Sprintf("%s", child.ProjectName)},
+ {"Project", child.ProjectId},
+ {"Environment", fmt.Sprintf("%s (%s)", child.EnvName, child.EnvId)},
+ {"Blueprint", fmt.Sprintf("%s (%s)", bpName, child.BlueprintProjectId)},
+ }
+
+ if child.OwnerEmail != "" {
+ rows = append(rows, []string{"Owner", child.OwnerEmail})
+ }
+
+ status, err := rdeGetEnvStatus(client, child.EnvId)
+ if err == nil {
+ rows = append(rows, []string{"Status", utils.GetStatusTextWithColor(status)})
+ }
+
+ if status == qovery.STATEENUM_DEPLOYED || status == qovery.STATEENUM_RESTARTED {
+ url := rdeGetWorkspaceUrl(client, child.EnvId)
+ if url != "" {
+ rows = append(rows, []string{"Workspace", url})
+ }
+ }
+
+ lastDeploy := rdeGetLastDeployTime(client, child.EnvId)
+ uptime := rdeFormatUptime(lastDeploy)
+ rows = append(rows, []string{"Uptime", uptime})
+ rows = append(rows, []string{"Console", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s", orgId, child.ProjectId, child.EnvId)})
+
+ rdePrintKeyValueTable(rows)
+
+ // List services
+ utils.Println("")
+ rdePrintEnvServices(client, child.EnvId)
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeStatusCmd)
+ rdeStatusCmd.Flags().StringVarP(&rdeName, "name", "n", "", "RDE Name")
+ rdeStatusCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+
+ _ = rdeStatusCmd.MarkFlagRequired("name")
+}
diff --git a/cmd/rde_stop.go b/cmd/rde_stop.go
new file mode 100644
index 00000000..9447a5a2
--- /dev/null
+++ b/cmd/rde_stop.go
@@ -0,0 +1,54 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeStopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop an RDE",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ child, err := rdeFindChildByName(client, orgId, fmt.Sprintf("rde-%s", rdeName))
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ _, _, err = client.EnvironmentActionsAPI.StopEnvironment(ctx(), child.EnvId).Execute()
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("stop failed: %w", err))
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ utils.Println(fmt.Sprintf("Request to stop RDE %s has been queued..", pterm.FgBlue.Sprintf("%s", rdeName)))
+
+ if watchFlag {
+ time.Sleep(3 * time.Second)
+ utils.WatchEnvironment(child.EnvId, qovery.STATEENUM_STOPPED, client)
+ }
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeStopCmd)
+ rdeStopCmd.Flags().StringVarP(&rdeName, "name", "n", "", "RDE Name")
+ rdeStopCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+ rdeStopCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch stop status until it completes or an error occurs")
+
+ _ = rdeStopCmd.MarkFlagRequired("name")
+}
diff --git a/cmd/rde_stop_all.go b/cmd/rde_stop_all.go
new file mode 100644
index 00000000..08dc80ab
--- /dev/null
+++ b/cmd/rde_stop_all.go
@@ -0,0 +1,62 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var rdeStopAllCmd = &cobra.Command{
+ Use: "stop-all",
+ Short: "Stop all RDE environments",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ var children []rdeChildInfo
+
+ if rdeBlueprintProjectName != "" {
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+ children, err = rdeListChildren(client, orgId, bp.ProjectId)
+ checkError(err)
+ } else {
+ children, err = rdeListAllChildren(client, orgId)
+ checkError(err)
+ }
+
+ if len(children) == 0 {
+ utils.Println("No RDE instances found.")
+ return
+ }
+
+ utils.Println(fmt.Sprintf("Stopping %d RDE environment(s)...", len(children)))
+ for _, child := range children {
+ if child.EnvId != "" {
+ _, _, err := client.EnvironmentActionsAPI.StopEnvironment(ctx(), child.EnvId).Execute()
+ if err != nil {
+ utils.Println(fmt.Sprintf(" Failed to stop: %s (%v)", pterm.FgBlue.Sprintf("%s", child.ProjectName), err))
+ } else {
+ utils.Println(fmt.Sprintf(" Request to stop %s has been queued..", pterm.FgBlue.Sprintf("%s", child.ProjectName)))
+ }
+ }
+ }
+ utils.Println("Done.")
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeStopAllCmd)
+ rdeStopAllCmd.Flags().StringVarP(&rdeBlueprintProjectName, "blueprint", "b", "", "Filter by Blueprint Project Name")
+ rdeStopAllCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+}
diff --git a/cmd/rde_sync.go b/cmd/rde_sync.go
new file mode 100644
index 00000000..b9bfc5a7
--- /dev/null
+++ b/cmd/rde_sync.go
@@ -0,0 +1,535 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+)
+
+// Sync option flags (set in rde_upgrade.go init)
+var rdeSyncAll bool
+var rdeSyncResources bool
+var rdeSyncPorts bool
+var rdeSyncHealthchecks bool
+var rdeSyncStorage bool
+
+// rdeSyncServicesFromBlueprint reads all services from the blueprint environment and updates
+// the matching services in the child environment. Services are matched by name.
+// Source/image config is always synced. Additional config is synced based on flags.
+func rdeSyncServicesFromBlueprint(client *qovery.APIClient, blueprintEnvId string, childEnvId string) int {
+ synced := 0
+ synced += rdeSyncContainers(client, blueprintEnvId, childEnvId)
+ synced += rdeSyncApplications(client, blueprintEnvId, childEnvId)
+ synced += rdeSyncJobs(client, blueprintEnvId, childEnvId)
+ synced += rdeSyncHelms(client, blueprintEnvId, childEnvId)
+ return synced
+}
+
+// --- Container sync ---
+
+func rdeSyncContainers(client *qovery.APIClient, blueprintEnvId string, childEnvId string) int {
+ bpContainers, _, err := client.ContainersAPI.ListContainer(ctx(), blueprintEnvId).Execute()
+ if err != nil || bpContainers == nil {
+ return 0
+ }
+ childContainers, _, err := client.ContainersAPI.ListContainer(ctx(), childEnvId).Execute()
+ if err != nil || childContainers == nil {
+ return 0
+ }
+
+ bpMap := make(map[string]qovery.ContainerResponse)
+ for _, c := range bpContainers.GetResults() {
+ bpMap[c.Name] = c
+ }
+
+ synced := 0
+ for _, child := range childContainers.GetResults() {
+ bp, ok := bpMap[child.Name]
+ if !ok {
+ continue
+ }
+
+ // Build storage from child or blueprint
+ var storage []qovery.ServiceStorageRequestStorageInner
+ srcStorage := child.Storage
+ if rdeSyncAll || rdeSyncStorage {
+ srcStorage = bp.Storage
+ }
+ for _, s := range srcStorage {
+ storage = append(storage, qovery.ServiceStorageRequestStorageInner{
+ Id: &s.Id,
+ Type: s.Type,
+ Size: s.Size,
+ MountPoint: s.MountPoint,
+ })
+ }
+
+ // Build ports from child or blueprint
+ var ports []qovery.ServicePortRequestPortsInner
+ srcPorts := child.Ports
+ if rdeSyncAll || rdeSyncPorts {
+ srcPorts = bp.Ports
+ }
+ for _, p := range srcPorts {
+ ports = append(ports, qovery.ServicePortRequestPortsInner{
+ Name: p.Name,
+ InternalPort: p.InternalPort,
+ ExternalPort: p.ExternalPort,
+ PubliclyAccessible: p.PubliclyAccessible,
+ IsDefault: p.IsDefault,
+ Protocol: &p.Protocol,
+ })
+ }
+
+ cpu := utils.Int32(child.Cpu)
+ memory := utils.Int32(child.Memory)
+ minInst := utils.Int32(child.MinRunningInstances)
+ maxInst := utils.Int32(child.MaxRunningInstances)
+ autoscaling := utils.ConvertAutoscalingResponseToRequest(child.Autoscaling)
+ if rdeSyncAll || rdeSyncResources {
+ cpu = utils.Int32(bp.Cpu)
+ memory = utils.Int32(bp.Memory)
+ minInst = utils.Int32(bp.MinRunningInstances)
+ maxInst = utils.Int32(bp.MaxRunningInstances)
+ autoscaling = utils.ConvertAutoscalingResponseToRequest(bp.Autoscaling)
+ }
+
+ healthchecks := child.Healthchecks
+ if rdeSyncAll || rdeSyncHealthchecks {
+ healthchecks = bp.Healthchecks
+ }
+
+ req := qovery.ContainerRequest{
+ Storage: storage,
+ Ports: ports,
+ Name: child.Name,
+ Description: child.Description,
+ RegistryId: bp.Registry.Id, // always sync source
+ ImageName: bp.ImageName, // always sync source
+ Tag: bp.Tag, // always sync source
+ Arguments: child.Arguments,
+ Entrypoint: child.Entrypoint,
+ Cpu: cpu,
+ Memory: memory,
+ MinRunningInstances: minInst,
+ MaxRunningInstances: maxInst,
+ Healthchecks: healthchecks,
+ AutoPreview: utils.Bool(child.AutoPreview),
+ AutoDeploy: *qovery.NewNullableBool(child.AutoDeploy),
+ Autoscaling: autoscaling,
+ }
+
+ _, _, err := client.ContainerMainCallsAPI.EditContainer(ctx(), child.Id).ContainerRequest(req).Execute()
+ if err != nil {
+ utils.Println(fmt.Sprintf(" WARNING: Failed to sync container %s: %v", child.Name, err))
+ } else {
+ utils.Println(fmt.Sprintf(" Synced container: %s (tag: %s)", pterm.FgBlue.Sprintf("%s", child.Name), bp.Tag))
+ synced++
+ }
+ }
+
+ return synced
+}
+
+// --- Application sync ---
+
+func rdeSyncApplications(client *qovery.APIClient, blueprintEnvId string, childEnvId string) int {
+ bpApps, _, err := client.ApplicationsAPI.ListApplication(ctx(), blueprintEnvId).Execute()
+ if err != nil || bpApps == nil {
+ return 0
+ }
+ childApps, _, err := client.ApplicationsAPI.ListApplication(ctx(), childEnvId).Execute()
+ if err != nil || childApps == nil {
+ return 0
+ }
+
+ bpMap := make(map[string]qovery.Application)
+ for _, a := range bpApps.GetResults() {
+ bpMap[a.Name] = a
+ }
+
+ synced := 0
+ for _, child := range childApps.GetResults() {
+ bp, ok := bpMap[child.Name]
+ if !ok {
+ continue
+ }
+
+ // Build git repository request from blueprint (always sync source)
+ var gitRepo *qovery.ApplicationGitRepositoryRequest
+ if bp.GitRepository != nil {
+ gitRepo = &qovery.ApplicationGitRepositoryRequest{
+ Url: bp.GitRepository.Url,
+ Branch: bp.GitRepository.Branch,
+ RootPath: bp.GitRepository.RootPath,
+ Provider: bp.GitRepository.Provider,
+ }
+ }
+
+ var storage []qovery.ServiceStorageRequestStorageInner
+ srcStorage := child.Storage
+ if rdeSyncAll || rdeSyncStorage {
+ srcStorage = bp.Storage
+ }
+ for _, s := range srcStorage {
+ storage = append(storage, qovery.ServiceStorageRequestStorageInner{
+ Id: &s.Id,
+ Type: s.Type,
+ Size: s.Size,
+ MountPoint: s.MountPoint,
+ })
+ }
+
+ cpu := child.Cpu
+ memory := child.Memory
+ minInst := child.MinRunningInstances
+ maxInst := child.MaxRunningInstances
+ if rdeSyncAll || rdeSyncResources {
+ cpu = bp.Cpu
+ memory = bp.Memory
+ minInst = bp.MinRunningInstances
+ maxInst = bp.MaxRunningInstances
+ }
+
+ healthchecks := child.Healthchecks
+ if rdeSyncAll || rdeSyncHealthchecks {
+ healthchecks = bp.Healthchecks
+ }
+
+ ports := child.Ports
+ if rdeSyncAll || rdeSyncPorts {
+ ports = bp.Ports
+ }
+
+ req := qovery.ApplicationEditRequest{
+ Storage: storage,
+ Name: &child.Name,
+ Description: child.Description,
+ GitRepository: gitRepo, // always sync source
+ BuildMode: bp.BuildMode, // always sync source
+ DockerfilePath: bp.DockerfilePath, // always sync source
+ Cpu: cpu,
+ Memory: memory,
+ MinRunningInstances: minInst,
+ MaxRunningInstances: maxInst,
+ Healthchecks: healthchecks,
+ AutoPreview: child.AutoPreview,
+ Ports: ports,
+ Arguments: child.Arguments,
+ Entrypoint: child.Entrypoint,
+ AutoDeploy: *qovery.NewNullableBool(child.AutoDeploy),
+ }
+
+ _, _, err := client.ApplicationMainCallsAPI.EditApplication(ctx(), child.Id).ApplicationEditRequest(req).Execute()
+ if err != nil {
+ utils.Println(fmt.Sprintf(" WARNING: Failed to sync application %s: %v", child.Name, err))
+ } else {
+ branch := ""
+ if bp.GitRepository != nil && bp.GitRepository.Branch != nil {
+ branch = *bp.GitRepository.Branch
+ }
+ utils.Println(fmt.Sprintf(" Synced application: %s (branch: %s)", pterm.FgBlue.Sprintf("%s", child.Name), branch))
+ synced++
+ }
+ }
+
+ return synced
+}
+
+// --- Job sync ---
+
+func rdeSyncJobs(client *qovery.APIClient, blueprintEnvId string, childEnvId string) int {
+ bpJobs, _, err := client.JobsAPI.ListJobs(ctx(), blueprintEnvId).Execute()
+ if err != nil || bpJobs == nil {
+ return 0
+ }
+ childJobs, _, err := client.JobsAPI.ListJobs(ctx(), childEnvId).Execute()
+ if err != nil || childJobs == nil {
+ return 0
+ }
+
+ bpMap := make(map[string]qovery.JobResponse)
+ for _, j := range bpJobs.GetResults() {
+ bpMap[utils.GetJobName(&j)] = j
+ }
+
+ synced := 0
+ for _, childJob := range childJobs.GetResults() {
+ childName := utils.GetJobName(&childJob)
+ bp, ok := bpMap[childName]
+ if !ok {
+ continue
+ }
+
+ childId := utils.GetJobId(&childJob)
+ bpSource := rdeJobResponseToRequestSource(&bp)
+ childDetail := rdeExtractJobDetail(&childJob)
+ if childDetail == nil || bpSource == nil {
+ continue
+ }
+
+ cpu := childDetail.cpu
+ memory := childDetail.memory
+ if rdeSyncAll || rdeSyncResources {
+ bpDetail := rdeExtractJobDetail(&bp)
+ if bpDetail != nil {
+ cpu = bpDetail.cpu
+ memory = bpDetail.memory
+ }
+ }
+
+ healthchecks := childDetail.healthchecks
+ if rdeSyncAll || rdeSyncHealthchecks {
+ bpDetail := rdeExtractJobDetail(&bp)
+ if bpDetail != nil {
+ healthchecks = bpDetail.healthchecks
+ }
+ }
+
+ req := qovery.JobRequest{
+ Name: childName,
+ Description: childDetail.description,
+ Cpu: cpu,
+ Memory: memory,
+ MaxNbRestart: childDetail.maxNbRestart,
+ MaxDurationSeconds: childDetail.maxDurationSeconds,
+ AutoPreview: childDetail.autoPreview,
+ Port: childDetail.port,
+ Source: bpSource, // always sync source
+ Healthchecks: healthchecks,
+ Schedule: childDetail.schedule,
+ AutoDeploy: childDetail.autoDeploy,
+ }
+
+ _, _, err := client.JobMainCallsAPI.EditJob(ctx(), childId).JobRequest(req).Execute()
+ if err != nil {
+ utils.Println(fmt.Sprintf(" WARNING: Failed to sync job %s: %v", childName, err))
+ } else {
+ utils.Println(fmt.Sprintf(" Synced job: %s", pterm.FgBlue.Sprintf("%s", childName)))
+ synced++
+ }
+ }
+
+ return synced
+}
+
+// rdeJobResponseToRequestSource converts a JobResponse source to a JobRequestAllOfSource.
+func rdeJobResponseToRequestSource(job *qovery.JobResponse) *qovery.JobRequestAllOfSource {
+ var source qovery.BaseJobResponseAllOfSource
+ if job.CronJobResponse != nil {
+ source = job.CronJobResponse.Source
+ } else if job.LifecycleJobResponse != nil {
+ source = job.LifecycleJobResponse.Source
+ } else {
+ return nil
+ }
+
+ result := &qovery.JobRequestAllOfSource{}
+
+ if source.BaseJobResponseAllOfSourceOneOf != nil {
+ // Image source
+ img := source.BaseJobResponseAllOfSourceOneOf.Image
+ reqImg := qovery.NewNullableJobRequestAllOfSourceImage(
+ &qovery.JobRequestAllOfSourceImage{
+ ImageName: &img.ImageName,
+ Tag: &img.Tag,
+ RegistryId: img.RegistryId,
+ },
+ )
+ result.Image = *reqImg
+ } else if source.BaseJobResponseAllOfSourceOneOf1 != nil {
+ // Docker/git source
+ docker := source.BaseJobResponseAllOfSourceOneOf1.Docker
+ var gitRepo *qovery.ApplicationGitRepositoryRequest
+ if docker.GitRepository != nil {
+ gitRepo = &qovery.ApplicationGitRepositoryRequest{
+ Url: docker.GitRepository.Url,
+ Branch: docker.GitRepository.Branch,
+ RootPath: docker.GitRepository.RootPath,
+ Provider: docker.GitRepository.Provider,
+ }
+ }
+ reqDocker := qovery.NewNullableJobRequestAllOfSourceDocker(
+ &qovery.JobRequestAllOfSourceDocker{
+ GitRepository: gitRepo,
+ DockerfilePath: docker.DockerfilePath,
+ DockerfileRaw: docker.DockerfileRaw,
+ DockerTargetBuildStage: docker.DockerTargetBuildStage,
+ },
+ )
+ result.Docker = *reqDocker
+ }
+
+ return result
+}
+
+// jobDetail holds common fields from CronJobResponse or LifecycleJobResponse.
+type jobDetail struct {
+ cpu *int32
+ memory *int32
+ description *string
+ maxNbRestart *int32
+ maxDurationSeconds *int32
+ autoPreview *bool
+ port qovery.NullableInt32
+ healthchecks qovery.Healthcheck
+ schedule *qovery.JobRequestAllOfSchedule
+ autoDeploy qovery.NullableBool
+}
+
+// rdeExtractJobDetail extracts common fields from a JobResponse.
+func rdeExtractJobDetail(job *qovery.JobResponse) *jobDetail {
+ if job.CronJobResponse != nil {
+ cj := job.CronJobResponse
+ cpu := cj.Cpu
+ mem := cj.Memory
+ autoPreview := utils.Bool(cj.AutoPreview)
+ tz := cj.Schedule.Cronjob.Timezone
+ schedule := &qovery.JobRequestAllOfSchedule{
+ Cronjob: &qovery.JobRequestAllOfScheduleCronjob{
+ ScheduledAt: cj.Schedule.Cronjob.ScheduledAt,
+ Timezone: &tz,
+ Entrypoint: cj.Schedule.Cronjob.Entrypoint,
+ Arguments: cj.Schedule.Cronjob.Arguments,
+ },
+ }
+ return &jobDetail{
+ cpu: &cpu, memory: &mem,
+ description: cj.Description, maxNbRestart: cj.MaxNbRestart,
+ maxDurationSeconds: cj.MaxDurationSeconds, autoPreview: autoPreview,
+ port: cj.Port, healthchecks: cj.Healthchecks, schedule: schedule,
+ autoDeploy: *qovery.NewNullableBool(cj.AutoDeploy),
+ }
+ } else if job.LifecycleJobResponse != nil {
+ lj := job.LifecycleJobResponse
+ cpu := lj.Cpu
+ mem := lj.Memory
+ autoPreview := utils.Bool(lj.AutoPreview)
+ schedule := &qovery.JobRequestAllOfSchedule{}
+ if lj.Schedule.OnStart != nil {
+ schedule.OnStart = &qovery.JobRequestAllOfScheduleOnStart{
+ Entrypoint: lj.Schedule.OnStart.Entrypoint,
+ Arguments: lj.Schedule.OnStart.Arguments,
+ }
+ }
+ if lj.Schedule.OnStop != nil {
+ schedule.OnStop = &qovery.JobRequestAllOfScheduleOnStart{
+ Entrypoint: lj.Schedule.OnStop.Entrypoint,
+ Arguments: lj.Schedule.OnStop.Arguments,
+ }
+ }
+ if lj.Schedule.OnDelete != nil {
+ schedule.OnDelete = &qovery.JobRequestAllOfScheduleOnStart{
+ Entrypoint: lj.Schedule.OnDelete.Entrypoint,
+ Arguments: lj.Schedule.OnDelete.Arguments,
+ }
+ }
+ return &jobDetail{
+ cpu: &cpu, memory: &mem,
+ description: lj.Description, maxNbRestart: lj.MaxNbRestart,
+ maxDurationSeconds: lj.MaxDurationSeconds, autoPreview: autoPreview,
+ port: lj.Port, healthchecks: lj.Healthchecks, schedule: schedule,
+ autoDeploy: *qovery.NewNullableBool(lj.AutoDeploy),
+ }
+ }
+ return nil
+}
+
+// --- Helm sync ---
+
+func rdeSyncHelms(client *qovery.APIClient, blueprintEnvId string, childEnvId string) int {
+ bpHelms, _, err := client.HelmsAPI.ListHelms(ctx(), blueprintEnvId).Execute()
+ if err != nil || bpHelms == nil {
+ return 0
+ }
+ childHelms, _, err := client.HelmsAPI.ListHelms(ctx(), childEnvId).Execute()
+ if err != nil || childHelms == nil {
+ return 0
+ }
+
+ bpMap := make(map[string]qovery.HelmResponse)
+ for _, h := range bpHelms.GetResults() {
+ bpMap[h.Name] = h
+ }
+
+ synced := 0
+ for _, child := range childHelms.GetResults() {
+ bp, ok := bpMap[child.Name]
+ if !ok {
+ continue
+ }
+
+ bpSource := rdeConvertHelmSource(&bp.Source)
+ if bpSource == nil {
+ continue
+ }
+
+ childValues := rdeConvertHelmValuesOverride(&child.ValuesOverride)
+
+ req := qovery.HelmRequest{
+ Name: child.Name,
+ Description: child.Description,
+ TimeoutSec: child.TimeoutSec,
+ AutoDeploy: child.AutoDeploy,
+ Source: *bpSource, // always sync source
+ Arguments: child.Arguments,
+ AllowClusterWideResources: &child.AllowClusterWideResources,
+ ValuesOverride: *childValues,
+ }
+
+ _, _, err := client.HelmMainCallsAPI.EditHelm(ctx(), child.Id).HelmRequest(req).Execute()
+ if err != nil {
+ utils.Println(fmt.Sprintf(" WARNING: Failed to sync helm %s: %v", child.Name, err))
+ } else {
+ utils.Println(fmt.Sprintf(" Synced helm: %s", pterm.FgBlue.Sprintf("%s", child.Name)))
+ synced++
+ }
+ }
+
+ return synced
+}
+
+// rdeConvertHelmSource converts a HelmResponseAllOfSource to a HelmRequestAllOfSource.
+func rdeConvertHelmSource(src *qovery.HelmResponseAllOfSource) *qovery.HelmRequestAllOfSource {
+ if src.HelmResponseAllOfSourceOneOf != nil {
+ // Git source
+ gitSrc := src.HelmResponseAllOfSourceOneOf.Git
+ gitRepo := &qovery.HelmGitRepositoryRequest{
+ Url: gitSrc.GitRepository.Url,
+ Branch: gitSrc.GitRepository.Branch,
+ RootPath: gitSrc.GitRepository.RootPath,
+ }
+ return &qovery.HelmRequestAllOfSource{
+ HelmRequestAllOfSourceOneOf: &qovery.HelmRequestAllOfSourceOneOf{
+ GitRepository: gitRepo,
+ },
+ }
+ } else if src.HelmResponseAllOfSourceOneOf1 != nil {
+ // Repository source
+ repoSrc := src.HelmResponseAllOfSourceOneOf1.Repository
+ repoId := repoSrc.Repository.Id
+ repoNullable := qovery.NullableString{}
+ repoNullable.Set(&repoId)
+ return &qovery.HelmRequestAllOfSource{
+ HelmRequestAllOfSourceOneOf1: &qovery.HelmRequestAllOfSourceOneOf1{
+ HelmRepository: &qovery.HelmRequestAllOfSourceOneOf1HelmRepository{
+ Repository: repoNullable,
+ ChartName: &repoSrc.ChartName,
+ ChartVersion: &repoSrc.ChartVersion,
+ },
+ },
+ }
+ }
+ return nil
+}
+
+// rdeConvertHelmValuesOverride converts HelmResponseAllOfValuesOverride to HelmRequestAllOfValuesOverride.
+func rdeConvertHelmValuesOverride(v *qovery.HelmResponseAllOfValuesOverride) *qovery.HelmRequestAllOfValuesOverride {
+ return &qovery.HelmRequestAllOfValuesOverride{
+ Set: v.Set,
+ SetString: v.SetString,
+ SetJson: v.SetJson,
+ }
+}
diff --git a/cmd/rde_upgrade.go b/cmd/rde_upgrade.go
new file mode 100644
index 00000000..3049ecf7
--- /dev/null
+++ b/cmd/rde_upgrade.go
@@ -0,0 +1,301 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeUpgradeCmd = &cobra.Command{
+ Use: "upgrade",
+ Short: "Upgrade RDE(s) from the updated blueprint",
+ Long: `Upgrade one or all RDE environments using one of two strategies:
+
+ image (default) - Redeploy the environment (re-pulls latest images)
+ reclone - Delete the environment, re-clone from blueprint, and deploy
+ WARNING: uncommitted changes will be lost with reclone
+
+If --name is provided, upgrades a single RDE. Otherwise, upgrades all RDEs.
+When upgrading multiple RDEs with reclone, environments are deleted in parallel
+for faster execution.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if rdeUpgradeStrategy == "" {
+ rdeUpgradeStrategy = "image"
+ }
+ if rdeUpgradeStrategy != "image" && rdeUpgradeStrategy != "reclone" {
+ utils.PrintlnError(fmt.Errorf("unknown strategy '%s'. Use 'image' or 'reclone'", rdeUpgradeStrategy))
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ if rdeName != "" {
+ // Upgrade a single RDE
+ child, err := rdeFindChildByName(client, orgId, fmt.Sprintf("rde-%s", rdeName))
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ if rdeUpgradeStrategy == "image" {
+ rdeUpgradeImage(client, child)
+ } else {
+ rdeUpgradeRecloneSingle(client, child)
+ }
+ } else {
+ // Upgrade all RDEs
+ var children []rdeChildInfo
+
+ if rdeBlueprintProjectName != "" {
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+ children, err = rdeListChildren(client, orgId, bp.ProjectId)
+ checkError(err)
+ } else {
+ children, err = rdeListAllChildren(client, orgId)
+ checkError(err)
+ }
+
+ if len(children) == 0 {
+ utils.Println("No RDE instances found.")
+ return
+ }
+
+ utils.Println(fmt.Sprintf("Upgrading %d RDE(s) (strategy: %s)...", len(children), rdeUpgradeStrategy))
+
+ if rdeUpgradeStrategy == "image" {
+ for _, child := range children {
+ rdeUpgradeImage(client, &child)
+ }
+ } else {
+ rdeUpgradeRecloneAll(client, children)
+ }
+ utils.Println("\nAll RDEs upgraded.")
+ }
+ },
+}
+
+// rdeUpgradeImage triggers a redeploy of an RDE environment.
+func rdeUpgradeImage(client *qovery.APIClient, child *rdeChildInfo) {
+ name := strings.TrimPrefix(child.ProjectName, "rde-")
+ utils.Println(fmt.Sprintf(" Upgrading %s (strategy: image - sync from blueprint and deploy)...", pterm.FgBlue.Sprintf("%s", name)))
+
+ // Resolve blueprint environment ID
+ bpEnvInfo, err := rdeFindBlueprintEnv(client, child.BlueprintProjectId)
+ if err != nil || bpEnvInfo == nil {
+ utils.Println(fmt.Sprintf(" ERROR: Could not find blueprint environment for %s", name))
+ return
+ }
+
+ // Sync service configurations from blueprint
+ synced := rdeSyncServicesFromBlueprint(client, bpEnvInfo.EnvId, child.EnvId)
+ if synced == 0 {
+ utils.Println(fmt.Sprintf(" No services matched between blueprint and %s, deploying as-is...", name))
+ } else {
+ utils.Println(fmt.Sprintf(" Synced %d service(s) from blueprint.", synced))
+ }
+
+ // Deploy
+ _, _, err = client.EnvironmentActionsAPI.DeployEnvironment(ctx(), child.EnvId).Execute()
+ if err != nil {
+ utils.Println(fmt.Sprintf(" WARNING: Deploy failed for %s: %v", name, err))
+ } else {
+ utils.Println(fmt.Sprintf(" Request to deploy %s has been queued..", pterm.FgBlue.Sprintf("%s", name)))
+ }
+}
+
+// rdeUpgradeRecloneSingle upgrades a single RDE by deleting its environment, waiting, and re-cloning from the blueprint.
+func rdeUpgradeRecloneSingle(client *qovery.APIClient, child *rdeChildInfo) {
+ name := strings.TrimPrefix(child.ProjectName, "rde-")
+ utils.Println(fmt.Sprintf(" Upgrading %s (strategy: reclone - full re-clone from blueprint)...", pterm.FgBlue.Sprintf("%s", name)))
+ utils.Println(" WARNING: Uncommitted changes will be lost. Code in git is safe.")
+
+ // Resolve blueprint environment ID
+ bpEnvInfo, err := rdeFindBlueprintEnv(client, child.BlueprintProjectId)
+ if err != nil || bpEnvInfo == nil {
+ utils.Println(fmt.Sprintf(" ERROR: Could not find blueprint environment for %s", name))
+ return
+ }
+
+ // Get blueprint cluster ID
+ bpClusterId := rdeGetBlueprintClusterId(client, bpEnvInfo.EnvId)
+
+ // Preserve owner email
+ ownerEmail := child.OwnerEmail
+
+ // Delete the current environment
+ utils.Println(fmt.Sprintf(" Deleting environment %s...", pterm.FgBlue.Sprintf("%s", child.EnvName)))
+ _, _ = client.EnvironmentMainCallsAPI.DeleteEnvironment(ctx(), child.EnvId).Execute()
+
+ // Wait for deletion
+ utils.Println(" Waiting for deletion to complete...")
+ rdeWaitForEnvsDeletion(client, []string{child.EnvId}, 120*time.Second)
+
+ // Re-clone from blueprint environment
+ newEnv := rdeCloneFromBlueprint(client, child, bpEnvInfo.EnvId, bpClusterId)
+ if newEnv == nil {
+ return
+ }
+
+ // Restore owner email
+ if ownerEmail != "" {
+ _ = utils.CreateEnvironmentVariable(client, child.ProjectId, newEnv.Id, rdeOwnerEmailVar, ownerEmail, false)
+ }
+
+ // Update TTL job
+ rdeUpdateTTLJob(client, newEnv.Id)
+
+ // Deploy
+ _, _, err = client.EnvironmentActionsAPI.DeployEnvironment(ctx(), newEnv.Id).Execute()
+ if err != nil {
+ utils.Println(fmt.Sprintf(" WARNING: Deploy failed after re-clone for %s: %v", name, err))
+ } else {
+ utils.Println(fmt.Sprintf(" Re-cloned and deploying %s (env: %s)", pterm.FgBlue.Sprintf("%s", name), newEnv.Id))
+ }
+}
+
+// rdeUpgradeRecloneAll upgrades multiple RDEs in parallel phases:
+// Phase 1: Delete all environments (fire all delete requests)
+// Phase 2: Wait for all deletions to complete
+// Phase 3: Re-clone all from their blueprint
+// Phase 4: Deploy all
+func rdeUpgradeRecloneAll(client *qovery.APIClient, children []rdeChildInfo) {
+ utils.Println(" WARNING: Uncommitted changes will be lost. Code in git is safe.")
+ utils.Println("")
+
+ // Pre-resolve all blueprint env IDs and cluster IDs (grouped by blueprint project ID)
+ type blueprintRef struct {
+ envId string
+ clusterId string
+ }
+ bpRefMap := make(map[string]*blueprintRef)
+ for _, child := range children {
+ if _, ok := bpRefMap[child.BlueprintProjectId]; !ok {
+ bpEnvInfo, err := rdeFindBlueprintEnv(client, child.BlueprintProjectId)
+ if err == nil && bpEnvInfo != nil {
+ clusterId := rdeGetBlueprintClusterId(client, bpEnvInfo.EnvId)
+ bpRefMap[child.BlueprintProjectId] = &blueprintRef{
+ envId: bpEnvInfo.EnvId,
+ clusterId: clusterId,
+ }
+ }
+ }
+ }
+
+ // Phase 1: Delete all environments
+ utils.Println(" Phase 1/4: Deleting old environments...")
+ var envIdsToWait []string
+ for _, child := range children {
+ name := strings.TrimPrefix(child.ProjectName, "rde-")
+ _, _ = client.EnvironmentMainCallsAPI.DeleteEnvironment(ctx(), child.EnvId).Execute()
+ envIdsToWait = append(envIdsToWait, child.EnvId)
+ utils.Println(fmt.Sprintf(" Delete requested: %s", pterm.FgBlue.Sprintf("%s", name)))
+ }
+
+ // Phase 2: Wait for all deletions
+ utils.Println("")
+ utils.Println(" Phase 2/4: Waiting for deletions to complete...")
+ rdeWaitForEnvsDeletion(client, envIdsToWait, 180*time.Second)
+ utils.Println(" Deletions complete.")
+
+ // Phase 3: Re-clone all from blueprint
+ utils.Println("")
+ utils.Println(" Phase 3/4: Cloning from blueprint...")
+ type cloneResult struct {
+ child rdeChildInfo
+ newEnv *qovery.Environment
+ }
+ var results []cloneResult
+ for _, child := range children {
+ name := strings.TrimPrefix(child.ProjectName, "rde-")
+ ref, ok := bpRefMap[child.BlueprintProjectId]
+ if !ok || ref == nil {
+ utils.Println(fmt.Sprintf(" ERROR: No blueprint environment found for %s, skipping", name))
+ continue
+ }
+
+ newEnv := rdeCloneFromBlueprint(client, &child, ref.envId, ref.clusterId)
+ if newEnv == nil {
+ continue
+ }
+
+ // Restore owner email
+ if child.OwnerEmail != "" {
+ _ = utils.CreateEnvironmentVariable(client, child.ProjectId, newEnv.Id, rdeOwnerEmailVar, child.OwnerEmail, false)
+ }
+
+ // Update TTL job
+ rdeUpdateTTLJob(client, newEnv.Id)
+
+ results = append(results, cloneResult{child: child, newEnv: newEnv})
+ utils.Println(fmt.Sprintf(" Cloned: %s (env: %s)", pterm.FgBlue.Sprintf("%s", name), newEnv.Id))
+ }
+
+ // Phase 4: Deploy all
+ utils.Println("")
+ utils.Println(" Phase 4/4: Deploying...")
+ for _, r := range results {
+ name := strings.TrimPrefix(r.child.ProjectName, "rde-")
+ _, _, err := client.EnvironmentActionsAPI.DeployEnvironment(ctx(), r.newEnv.Id).Execute()
+ if err != nil {
+ utils.Println(fmt.Sprintf(" WARNING: Deploy failed for %s: %v", name, err))
+ } else {
+ utils.Println(fmt.Sprintf(" Request to deploy %s has been queued..", pterm.FgBlue.Sprintf("%s", name)))
+ }
+ }
+}
+
+// rdeCloneFromBlueprint clones the blueprint environment into an RDE's project.
+func rdeCloneFromBlueprint(client *qovery.APIClient, child *rdeChildInfo, blueprintEnvId string, clusterId string) *qovery.Environment {
+ name := strings.TrimPrefix(child.ProjectName, "rde-")
+
+ cloneReq := qovery.CloneEnvironmentRequest{
+ Name: "workspace",
+ ProjectId: &child.ProjectId,
+ Mode: qovery.ENVIRONMENTMODEENUM_DEVELOPMENT.Ptr(),
+ }
+
+ if clusterId != "" {
+ cloneReq.ClusterId = &clusterId
+ }
+
+ newEnv, _, err := client.EnvironmentActionsAPI.CloneEnvironment(ctx(), blueprintEnvId).
+ CloneEnvironmentRequest(cloneReq).Execute()
+ if err != nil {
+ utils.Println(fmt.Sprintf(" ERROR: Re-clone failed for %s: %v", name, err))
+ return nil
+ }
+
+ return newEnv
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeUpgradeCmd)
+ rdeUpgradeCmd.Flags().StringVarP(&rdeName, "name", "n", "", "RDE Name (omit to upgrade all)")
+ rdeUpgradeCmd.Flags().StringVarP(&rdeUpgradeStrategy, "strategy", "s", "image", "Upgrade strategy: 'image' (sync source and deploy) or 'reclone' (full re-clone)")
+ rdeUpgradeCmd.Flags().StringVarP(&rdeBlueprintProjectName, "blueprint", "b", "", "Filter by Blueprint Project Name (when upgrading all)")
+ rdeUpgradeCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+
+ // Sync scope flags (used with --strategy image)
+ rdeUpgradeCmd.Flags().BoolVarP(&rdeSyncAll, "sync-all", "", false, "Sync all config from blueprint (resources, ports, healthchecks, storage)")
+ rdeUpgradeCmd.Flags().BoolVarP(&rdeSyncResources, "sync-resources", "", false, "Also sync CPU, memory, and instance counts from blueprint")
+ rdeUpgradeCmd.Flags().BoolVarP(&rdeSyncPorts, "sync-ports", "", false, "Also sync port configuration from blueprint")
+ rdeUpgradeCmd.Flags().BoolVarP(&rdeSyncHealthchecks, "sync-healthchecks", "", false, "Also sync health check configuration from blueprint")
+ rdeUpgradeCmd.Flags().BoolVarP(&rdeSyncStorage, "sync-storage", "", false, "Also sync storage volumes from blueprint")
+}
diff --git a/cmd/rde_urls.go b/cmd/rde_urls.go
new file mode 100644
index 00000000..5e6de9ab
--- /dev/null
+++ b/cmd/rde_urls.go
@@ -0,0 +1,81 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var rdeUrlsCmd = &cobra.Command{
+ Use: "urls",
+ Short: "List workspace URLs for running RDEs",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ orgId, err := rdeGetOrgId(client)
+ checkError(err)
+
+ var children []rdeChildInfo
+
+ if rdeBlueprintProjectName != "" {
+ bp, err := rdeFindBlueprintByProjectName(client, orgId, rdeBlueprintProjectName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+ children, err = rdeListChildren(client, orgId, bp.ProjectId)
+ checkError(err)
+ } else {
+ children, err = rdeListAllChildren(client, orgId)
+ checkError(err)
+ }
+
+ if len(children) == 0 {
+ utils.Println("No RDE instances found.")
+ return
+ }
+
+ var data [][]string
+ for _, child := range children {
+ if child.EnvId == "" {
+ continue
+ }
+ status, err := rdeGetEnvStatus(client, child.EnvId)
+ if err != nil {
+ continue
+ }
+ if status == qovery.STATEENUM_DEPLOYED || status == qovery.STATEENUM_RESTARTED {
+ url := rdeGetWorkspaceUrl(client, child.EnvId)
+ if url == "" {
+ url = "-"
+ }
+ data = append(data, []string{child.ProjectName, url})
+ }
+ }
+
+ if len(data) == 0 {
+ utils.Println("No running RDEs with workspace URLs found.")
+ return
+ }
+
+ err = utils.PrintTable([]string{"Name", "Workspace URL"}, data)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ utils.Println(fmt.Sprintf("\n%d running RDE(s) with workspace URLs.", len(data)))
+ },
+}
+
+func init() {
+ rdeCmd.AddCommand(rdeUrlsCmd)
+ rdeUrlsCmd.Flags().StringVarP(&rdeBlueprintProjectName, "blueprint", "b", "", "Filter by Blueprint Project Name")
+ rdeUrlsCmd.Flags().StringVarP(&organizationName, "organization", "o", "", "Organization Name")
+}
diff --git a/cmd/root.go b/cmd/root.go
index 41f58c87..9c5b5479 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,12 +1,14 @@
package cmd
import (
- "github.com/getsentry/sentry-go"
- "github.com/qovery/qovery-cli/pkg"
+ // "github.com/getsentry/sentry-go"
+ // "github.com/qovery/qovery-cli/pkg"
+ "os"
+
"github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-cli/variable"
"github.com/spf13/cobra"
- "os"
- "time"
+ // "time"
)
var rootCmd = &cobra.Command{
@@ -15,6 +17,7 @@ var rootCmd = &cobra.Command{
}
func Execute() {
+ utils.Capture(rootCmd)
if err := rootCmd.Execute(); err != nil {
utils.PrintlnError(err)
os.Exit(0)
@@ -23,6 +26,7 @@ func Execute() {
func init() {
cobra.OnInitialize(initConfig)
+ rootCmd.PersistentFlags().BoolVar(&variable.Verbose, "verbose", false, "Verbose output")
}
func initConfig() {
@@ -33,34 +37,44 @@ func initConfig() {
os.Exit(0)
}
}
- initSentry()
+ //initSentry()
}
-func initSentry() {
- pkg.GetCurrentVersion()
- err := sentry.Init(sentry.ClientOptions{
- Dsn: "https://199e1e8385d94377a98676dadcd77e2d@o471935.ingest.sentry.io/5866472",
- Environment: "prod",
- Release: pkg.GetCurrentVersion(),
- // Enable printing of SDK debug messages.
- // Useful when getting started or trying to figure something out.
- Debug: false,
- BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
- if len(event.Exception) > 0 && len(event.Exception[0].Stacktrace.Frames) > 0 {
- frames := event.Exception[0].Stacktrace.Frames
- event.Exception[0].Stacktrace.Frames = frames[:len(frames)-1]
- frames = event.Exception[0].Stacktrace.Frames
- path := frames[len(frames)-1].AbsPath
- event.Transaction = path
- }
- return event
- },
- })
- if err != nil {
- utils.PrintlnError(err)
- }
- // Flush buffered events before the program terminates.
- // Set the timeout to the maximum duration the program can afford to wait.
- defer sentry.Recover()
- defer sentry.Flush(5 * time.Second)
-}
+//func initSentry() {
+// pkg.GetCurrentVersion()
+// err := sentry.Init(sentry.ClientOptions{
+// Dsn: "https://199e1e8385d94377a98676dadcd77e2d@o471935.ingest.sentry.io/5866472",
+// Environment: "prod",
+// Release: pkg.GetCurrentVersion(),
+// // Enable printing of SDK debug messages.
+// // Useful when getting started or trying to figure something out.
+// Debug: false,
+// BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
+// // should not happen by design
+// if event == nil {
+// return event
+// }
+// if event.Exception == nil {
+// return event
+// }
+// if len(event.Exception) > 0 && (event.Exception[0].Stacktrace == nil || event.Exception[0].Stacktrace.Frames == nil) {
+// return event
+// }
+// if len(event.Exception[0].Stacktrace.Frames) > 0 {
+// frames := event.Exception[0].Stacktrace.Frames
+// event.Exception[0].Stacktrace.Frames = frames[:len(frames)-1]
+// frames = event.Exception[0].Stacktrace.Frames
+// path := frames[len(frames)-1].AbsPath
+// event.Transaction = path
+// }
+// return event
+// },
+// })
+// if err != nil {
+// utils.PrintlnError(err)
+// }
+// // Flush buffered events before the program terminates.
+// // Set the timeout to the maximum duration the program can afford to wait.
+// defer sentry.Recover()
+// defer sentry.Flush(5 * time.Second)
+//}
diff --git a/cmd/service.go b/cmd/service.go
new file mode 100644
index 00000000..1e7b4509
--- /dev/null
+++ b/cmd/service.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var serviceCmd = &cobra.Command{
+ Use: "service",
+ Short: "Manage services",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(serviceCmd)
+}
diff --git a/cmd/service_deploy.go b/cmd/service_deploy.go
new file mode 100644
index 00000000..f53e3844
--- /dev/null
+++ b/cmd/service_deploy.go
@@ -0,0 +1,292 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var (
+ serviceDeployName string
+ serviceDeployNames string
+ serviceDeployVersion string
+ serviceDeployValuesOverrideVersion string
+ serviceDeployWatchFlag bool
+)
+
+var serviceDeployCmd = &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy a service (application, container, database, job, or helm)",
+ Long: `Deploy a service by automatically detecting its type.
+This command works with applications, containers, databases, jobs (cronjobs and lifecycle), and helm charts.
+
+The --version parameter accepts:
+ - Git commit IDs (for applications, git-based jobs, git-based helms)
+ - Container image tags (for containers, image-based jobs)
+ - Helm chart versions (for helm repository charts)
+
+For helm charts, you can also specify:
+ - --values-override-version: Git commit ID for helm values override
+
+Examples:
+ qovery service deploy -n my-app --version abc123
+ qovery service deploy -n my-container --version v1.2.3
+ qovery service deploy -n my-database
+ qovery service deploy -n my-helm-repo --version 1.2.3
+ qovery service deploy -n my-helm-git --version abc123
+ qovery service deploy -n my-helm --version 1.2.3 --values-override-version def456
+ qovery service deploy --services "service1,service2,service3"`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateServiceDeployArguments(serviceDeployName, serviceDeployNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ // Get all services to deploy
+ servicesToDeploy := getServicesToDeployByNames(client, envId, serviceDeployName, serviceDeployNames)
+
+ if len(servicesToDeploy) == 0 {
+ utils.PrintlnError(fmt.Errorf("no services found to deploy"))
+ os.Exit(1)
+ }
+
+ // Group services by type
+ var applications []*qovery.Application
+ var containers []*qovery.ContainerResponse
+ var databases []*qovery.Database
+ var jobs []*qovery.JobResponse
+ var helms []*qovery.HelmResponse
+
+ for _, svc := range servicesToDeploy {
+ switch svc.Type {
+ case utils.ApplicationType:
+ applications = append(applications, svc.Application)
+ case utils.ContainerType:
+ containers = append(containers, svc.Container)
+ case utils.DatabaseType:
+ databases = append(databases, svc.Database)
+ case utils.JobType:
+ jobs = append(jobs, svc.Job)
+ case utils.HelmType:
+ helms = append(helms, svc.Helm)
+ }
+ }
+
+ // Deploy services
+ var err error
+ if len(applications) > 0 {
+ err = utils.DeployApplications(client, envId, applications, serviceDeployVersion)
+ checkError(err)
+ }
+ if len(containers) > 0 {
+ err = utils.DeployContainers(client, envId, containers, serviceDeployVersion)
+ checkError(err)
+ }
+ if len(databases) > 0 {
+ err = utils.DeployDatabases(client, envId, databases)
+ checkError(err)
+ }
+ if len(jobs) > 0 {
+ err = utils.DeployJobs(client, envId, jobs, serviceDeployVersion, serviceDeployVersion)
+ checkError(err)
+ }
+ if len(helms) > 0 {
+ err = utils.DeployHelms(client, envId, helms, serviceDeployVersion, serviceDeployVersion, serviceDeployValuesOverrideVersion)
+ checkError(err)
+ }
+
+ // Print confirmation
+ serviceNames := make([]string, len(servicesToDeploy))
+ for i, svc := range servicesToDeploy {
+ serviceNames[i] = svc.Name
+ }
+ utils.Println(fmt.Sprintf("Request to deploy service(s) %s has been queued..",
+ pterm.FgBlue.Sprintf("%s", strings.Join(serviceNames, ", "))))
+
+ // Watch deployment
+ watchServiceDeployment(client, envId, servicesToDeploy, serviceDeployWatchFlag)
+ },
+}
+
+type serviceDeployInfo struct {
+ Name string
+ Type utils.ServiceType
+ Application *qovery.Application
+ Container *qovery.ContainerResponse
+ Database *qovery.Database
+ Job *qovery.JobResponse
+ Helm *qovery.HelmResponse
+}
+
+func validateServiceDeployArguments(serviceName string, serviceNames string) {
+ if serviceName == "" && serviceNames == "" {
+ utils.PrintlnError(fmt.Errorf("use either --service or --services"))
+ os.Exit(1)
+ panic("unreachable")
+ }
+
+ if serviceName != "" && serviceNames != "" {
+ utils.PrintlnError(fmt.Errorf("use either --service or --services, not both"))
+ os.Exit(1)
+ panic("unreachable")
+ }
+}
+
+func getServicesToDeployByNames(
+ client *qovery.APIClient,
+ environmentId string,
+ serviceName string,
+ serviceNames string,
+) []serviceDeployInfo {
+ var result []serviceDeployInfo
+
+ // Build list of service names to look for
+ var namesToFind []string
+ if serviceName != "" {
+ namesToFind = append(namesToFind, serviceName)
+ }
+ if serviceNames != "" {
+ for _, name := range strings.Split(serviceNames, ",") {
+ namesToFind = append(namesToFind, strings.TrimSpace(name))
+ }
+ }
+
+ // Get all services from the environment
+ applications, _, err := client.ApplicationsAPI.ListApplication(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ databases, _, err := client.DatabasesAPI.ListDatabase(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ jobs, _, err := client.JobsAPI.ListJobs(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), environmentId).Execute()
+ checkError(err)
+
+ // Find each service by name
+ for _, name := range namesToFind {
+ found := false
+
+ // Check applications
+ if app := utils.FindByApplicationName(applications.GetResults(), name); app != nil {
+ result = append(result, serviceDeployInfo{
+ Name: name,
+ Type: utils.ApplicationType,
+ Application: app,
+ })
+ found = true
+ continue
+ }
+
+ // Check containers
+ if container := utils.FindByContainerName(containers.GetResults(), name); container != nil {
+ result = append(result, serviceDeployInfo{
+ Name: name,
+ Type: utils.ContainerType,
+ Container: container,
+ })
+ found = true
+ continue
+ }
+
+ // Check databases
+ if database := utils.FindByDatabaseName(databases.GetResults(), name); database != nil {
+ result = append(result, serviceDeployInfo{
+ Name: name,
+ Type: utils.DatabaseType,
+ Database: database,
+ })
+ found = true
+ continue
+ }
+
+ // Check jobs
+ if job := utils.FindByJobName(jobs.GetResults(), name); job != nil {
+ result = append(result, serviceDeployInfo{
+ Name: name,
+ Type: utils.JobType,
+ Job: job,
+ })
+ found = true
+ continue
+ }
+
+ // Check helms
+ if helm := utils.FindByHelmName(helms.GetResults(), name); helm != nil {
+ result = append(result, serviceDeployInfo{
+ Name: name,
+ Type: utils.HelmType,
+ Helm: helm,
+ })
+ found = true
+ continue
+ }
+
+ if !found {
+ utils.PrintlnError(fmt.Errorf("service '%s' not found", name))
+ utils.PrintlnInfo("You can list all services with: qovery service list")
+ os.Exit(1)
+ panic("unreachable")
+ }
+ }
+
+ return result
+}
+
+func watchServiceDeployment(
+ client *qovery.APIClient,
+ envId string,
+ services []serviceDeployInfo,
+ watchFlag bool,
+) {
+ if !watchFlag {
+ return
+ }
+
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent from race condition)
+
+ if len(services) == 1 {
+ // Watch single service
+ svc := services[0]
+ switch svc.Type {
+ case utils.ApplicationType:
+ utils.WatchApplication(svc.Application.Id, envId, client)
+ case utils.ContainerType:
+ utils.WatchContainer(svc.Container.Id, envId, client)
+ case utils.DatabaseType:
+ utils.WatchDatabase(svc.Database.Id, envId, client)
+ case utils.JobType:
+ jobId := utils.GetJobId(svc.Job)
+ utils.WatchJob(jobId, envId, client)
+ case utils.HelmType:
+ utils.WatchHelm(svc.Helm.Id, envId, client)
+ }
+ } else {
+ // Watch entire environment
+ utils.WatchEnvironment(envId, qovery.STATEENUM_DEPLOYED, client)
+ }
+}
+
+func init() {
+ serviceCmd.AddCommand(serviceDeployCmd)
+ serviceDeployCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ serviceDeployCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ serviceDeployCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ serviceDeployCmd.Flags().StringVarP(&serviceDeployName, "service", "n", "", "Service Name")
+ serviceDeployCmd.Flags().StringVarP(&serviceDeployNames, "services", "", "", "Service Names (comma separated) Example: --services \"svc1,svc2,svc3\"")
+ serviceDeployCmd.Flags().StringVarP(&serviceDeployVersion, "version", "v", "", "Version (git commit ID, image tag, or chart version)")
+ serviceDeployCmd.Flags().StringVarP(&serviceDeployValuesOverrideVersion, "values-override-version", "", "", "Helm values override version (git commit ID)")
+ serviceDeployCmd.Flags().BoolVarP(&serviceDeployWatchFlag, "watch", "w", false, "Watch service status until it's ready or an error occurs")
+}
diff --git a/cmd/service_list.go b/cmd/service_list.go
new file mode 100644
index 00000000..f3cfcdc8
--- /dev/null
+++ b/cmd/service_list.go
@@ -0,0 +1,672 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/go-errors/errors"
+
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var (
+ id string
+ organizationName string
+ projectName string
+ environmentName string
+ watchFlag bool
+ markdownFlag bool
+ jiraFlag bool
+ jsonFlag bool
+ servicesJson string
+)
+
+var serviceListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List services",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ orgId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ apps, _, err := client.ApplicationsAPI.ListApplication(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ databases, _, err := client.DatabasesAPI.ListDatabase(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ containers, _, err := client.ContainersAPI.ListContainer(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ jobs, _, err := client.JobsAPI.ListJobs(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ helms, _, err := client.HelmsAPI.ListHelms(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if markdownFlag {
+ markdown := getMarkdownOutput(*client, orgId, projectId, envId, apps.GetResults(), containers.GetResults(), jobs.GetResults(), databases.GetResults())
+ fmt.Print(markdown)
+ return
+ }
+
+ if jiraFlag {
+ jira := getJiraOutput(*client, orgId, projectId, envId, apps.GetResults(), containers.GetResults(), jobs.GetResults(), databases.GetResults())
+ fmt.Print(jira)
+ return
+ }
+
+ if jsonFlag {
+ j := getServiceJsonOutput(*statuses, apps.GetResults(), containers.GetResults(), jobs.GetResults(), databases.GetResults(), helms.GetResults())
+ fmt.Print(j)
+ return
+ }
+
+ var data [][]string
+
+ for _, app := range apps.GetResults() {
+ data = append(data, []string{app.GetName(), "Application", utils.FindStatusTextWithColor(statuses.GetApplications(), app.Id)})
+ }
+
+ for _, container := range containers.GetResults() {
+ data = append(data, []string{container.Name, "Container", utils.FindStatusTextWithColor(statuses.GetContainers(), container.Id)})
+ }
+
+ for _, job := range jobs.GetResults() {
+ jobType := "Lifecycle"
+ if job.CronJobResponse != nil {
+ jobType = "Cronjob"
+ }
+
+ data = append(data, []string{utils.GetJobName(&job), jobType, utils.FindStatusTextWithColor(statuses.GetJobs(), utils.GetJobId(&job))})
+ }
+
+ for _, database := range databases.GetResults() {
+ data = append(data, []string{database.Name, "Database", utils.FindStatusTextWithColor(statuses.GetDatabases(), database.Id)})
+ }
+
+ for _, helm := range helms.GetResults() {
+ data = append(data, []string{helm.Name, "Helm", utils.FindStatusTextWithColor(statuses.GetHelms(), helm.Id)})
+ }
+
+ err = utils.PrintTable([]string{"Name", "Type", "Status"}, data)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getOrganizationProjectEnvironmentContextResourcesIds(qoveryAPIClient *qovery.APIClient) (string, string, string, error) {
+ organizationId, err := usercontext.GetOrganizationContextResourceId(qoveryAPIClient, organizationName)
+ if err != nil {
+ return "", "", "", err
+ }
+
+ projectId, err := getProjectContextResourceId(qoveryAPIClient, projectName, organizationId)
+ if err != nil {
+ return organizationId, "", "", err
+ }
+
+ environmentId, err := getEnvironmentContextResourceId(qoveryAPIClient, environmentName, projectId)
+ if err != nil {
+ return organizationId, projectId, "", err
+ }
+
+ return organizationId, projectId, environmentId, nil
+}
+
+func getEnvironmentIdFromContextPanicInCaseOfError(client *qovery.APIClient) string {
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+ checkError(err)
+ return envId
+}
+
+func getOrganizationProjectContextResourcesIds(qoveryAPIClient *qovery.APIClient) (string, string, error) {
+ organizationId, err := usercontext.GetOrganizationContextResourceId(qoveryAPIClient, organizationName)
+ if err != nil {
+ return "", "", err
+ }
+
+ projectId, err := getProjectContextResourceId(qoveryAPIClient, projectName, organizationId)
+ if err != nil {
+ return organizationId, "", err
+ }
+
+ return organizationId, projectId, nil
+}
+
+func getProjectContextResourceId(qoveryAPIClient *qovery.APIClient, projectName string, organizationId string) (string, error) {
+ if strings.TrimSpace(projectName) == "" {
+ id, _, err := utils.CurrentProject(true)
+ if err != nil {
+ return "", err
+ }
+
+ return string(id), nil
+ }
+
+ if strings.TrimSpace(organizationId) == "" {
+ // avoid making a call to the API if the organization id is not set
+ return "", nil
+ }
+
+ // find project id by name
+ projects, _, err := qoveryAPIClient.ProjectsAPI.ListProject(context.Background(), organizationId).Execute()
+ if err != nil {
+ return "", err
+ }
+
+ project := utils.FindByProjectName(projects.GetResults(), projectName)
+ if project == nil {
+ return "", errors.Errorf("project %s not found", projectName)
+ }
+
+ return project.Id, nil
+}
+
+func getEnvironmentContextResourceId(qoveryAPIClient *qovery.APIClient, environmentName string, projectId string) (string, error) {
+ if strings.TrimSpace(environmentName) == "" {
+ id, _, err := utils.CurrentEnvironment(true)
+ if err != nil {
+ return "", err
+ }
+
+ return string(id), nil
+ }
+
+ if strings.TrimSpace(projectId) == "" {
+ // avoid making a call to the API if the project id is not set
+ return "", nil
+ }
+
+ // find environment id by name
+ environments, _, err := qoveryAPIClient.EnvironmentsAPI.ListEnvironment(context.Background(), projectId).Execute()
+ if err != nil {
+ return "", err
+ }
+
+ environment := utils.FindByEnvironmentName(environments.GetResults(), environmentName)
+ if environment == nil {
+ return "", errors.Errorf("environment %s not found", environmentName)
+ }
+
+ return environment.Id, nil
+}
+
+func getServiceContextResourceId(qoveryAPIClient *qovery.APIClient, serviceName string, environmentId string) (*utils.Service, error) {
+ if strings.TrimSpace(serviceName) == "" {
+ service, err := utils.CurrentService(true)
+ if err != nil {
+ return nil, err
+ }
+
+ return service, nil
+ }
+
+ if strings.TrimSpace(environmentId) == "" {
+ // avoid making a call to the API if the environment id is not set
+ return nil, nil
+ }
+
+ // try to get service if application
+ application, _ := getApplicationContextResource(qoveryAPIClient, serviceName, environmentId)
+ if application != nil {
+ return &utils.Service{
+ ID: utils.Id(application.Id),
+ Name: utils.Name(application.Name),
+ Type: utils.ApplicationType,
+ }, nil
+ }
+
+ // try to get service if container
+ container, _ := getContainerContextResource(qoveryAPIClient, serviceName, environmentId)
+ if container != nil {
+ return &utils.Service{
+ ID: utils.Id(container.Id),
+ Name: utils.Name(container.Name),
+ Type: utils.ContainerType,
+ }, nil
+ }
+
+ // try to get service if job
+ job, _ := getJobContextResource(qoveryAPIClient, serviceName, environmentId)
+ if job != nil && job.CronJobResponse != nil {
+ return &utils.Service{
+ ID: utils.Id(job.CronJobResponse.Id),
+ Name: utils.Name(job.CronJobResponse.Name),
+ Type: utils.JobType,
+ }, nil
+ }
+ if job != nil && job.LifecycleJobResponse != nil {
+ return &utils.Service{
+ ID: utils.Id(job.LifecycleJobResponse.Id),
+ Name: utils.Name(job.LifecycleJobResponse.Name),
+ Type: utils.JobType,
+ }, nil
+ }
+
+ // try to get service if helm
+ helm, _ := getHelmContextResource(qoveryAPIClient, serviceName, environmentId)
+ if helm != nil {
+ return &utils.Service{
+ ID: utils.Id(helm.Id),
+ Name: utils.Name(helm.Name),
+ Type: utils.HelmType,
+ }, nil
+ }
+
+ // try to get service if database
+ database, _ := getDatabaseContextResource(qoveryAPIClient, serviceName, environmentId)
+ if database != nil {
+ return &utils.Service{
+ ID: utils.Id(database.Id),
+ Name: utils.Name(database.Name),
+ Type: utils.DatabaseType,
+ }, nil
+ }
+
+ return nil, errors.Errorf("service %s not found", serviceName)
+}
+
+func getApplicationContextResource(qoveryAPIClient *qovery.APIClient, applicationName string, environmentId string) (*qovery.Application, error) {
+ if strings.TrimSpace(environmentId) == "" {
+ // avoid making a call to the API if the environment id is not set
+ return nil, nil
+ }
+
+ // find applications id by name
+ applications, _, err := qoveryAPIClient.ApplicationsAPI.ListApplication(context.Background(), environmentId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ application := utils.FindByApplicationName(applications.GetResults(), applicationName)
+
+ if application == nil {
+ return nil, errors.Errorf("application %s not found", applicationName)
+ }
+
+ return application, nil
+}
+
+func getDatabaseContextResource(qoveryAPIClient *qovery.APIClient, databaseName string, environmentId string) (*qovery.Database, error) {
+ if strings.TrimSpace(environmentId) == "" {
+ // avoid making a call to the API if the environment id is not set
+ return nil, nil
+ }
+
+ // find database id by name
+ databases, _, err := qoveryAPIClient.DatabasesAPI.ListDatabase(context.Background(), environmentId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ database := utils.FindByDatabaseName(databases.GetResults(), databaseName)
+
+ if database == nil {
+ return nil, errors.Errorf("application %s not found", applicationName)
+ }
+
+ return database, nil
+}
+
+func getContainerContextResource(qoveryAPIClient *qovery.APIClient, containerName string, environmentId string) (*qovery.ContainerResponse, error) {
+ if strings.TrimSpace(environmentId) == "" {
+ // avoid making a call to the API if the environment id is not set
+ return nil, nil
+ }
+
+ // find containers id by name
+ containers, _, err := qoveryAPIClient.ContainersAPI.ListContainer(context.Background(), environmentId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ container := utils.FindByContainerName(containers.GetResults(), containerName)
+
+ if container == nil {
+ return nil, errors.Errorf("container %s not found", containerName)
+ }
+
+ return container, nil
+}
+
+func getJobContextResource(qoveryAPIClient *qovery.APIClient, jobName string, environmentId string) (*qovery.JobResponse, error) {
+ if strings.TrimSpace(environmentId) == "" {
+ // avoid making a call to the API if the environment id is not set
+ return nil, nil
+ }
+
+ // find jobs id by name
+ jobs, _, err := qoveryAPIClient.JobsAPI.ListJobs(context.Background(), environmentId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ job := utils.FindByJobName(jobs.GetResults(), jobName)
+
+ if job == nil {
+ return nil, errors.Errorf("job %s not found", jobName)
+ }
+
+ return job, nil
+}
+
+func getHelmContextResource(qoveryAPIClient *qovery.APIClient, helmName string, environmentId string) (*qovery.HelmResponse, error) {
+ if strings.TrimSpace(environmentId) == "" {
+ // avoid making a call to the API if the environment id is not set
+ return nil, nil
+ }
+
+ // find helms id by name
+ helms, _, err := qoveryAPIClient.HelmsAPI.ListHelms(context.Background(), environmentId).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ helm := utils.FindByHelmName(helms.GetResults(), helmName)
+
+ if helm == nil {
+ return nil, errors.Errorf("helm %s not found", helmName)
+ }
+
+ return helm, nil
+}
+
+func getServiceJsonOutput(statuses qovery.EnvironmentStatuses, apps []qovery.Application, containers []qovery.ContainerResponse, jobs []qovery.JobResponse, databases []qovery.Database, helms []qovery.HelmResponse) string {
+ var results []interface{}
+
+ for _, app := range apps {
+ m := map[string]interface{}{
+ "id": app.Id,
+ "name": app.Name,
+ "type": "application",
+ "status": utils.FindStatus(statuses.GetApplications(), app.Id),
+ }
+
+ results = append(results, m)
+ }
+
+ for _, container := range containers {
+ m := map[string]interface{}{
+ "id": container.Id,
+ "name": container.Name,
+ "type": "container",
+ "status": utils.FindStatus(statuses.GetContainers(), container.Id),
+ }
+
+ results = append(results, m)
+ }
+
+ for _, job := range jobs {
+ jobType := "lifecycle"
+ if job.CronJobResponse != nil {
+ jobType = "cronjob"
+ }
+
+ m := map[string]interface{}{
+ "id": utils.GetJobId(&job),
+ "name": utils.GetJobName(&job),
+ "type": jobType,
+ "status": utils.FindStatus(statuses.GetJobs(), utils.GetJobId(&job)),
+ }
+
+ results = append(results, m)
+ }
+
+ for _, helm := range helms {
+ m := map[string]interface{}{
+ "id": helm.Id,
+ "name": helm.Name,
+ "type": "helm",
+ "status": utils.FindStatus(statuses.GetHelms(), helm.Id),
+ }
+
+ results = append(results, m)
+ }
+
+ for _, db := range databases {
+ m := map[string]interface{}{
+ "id": db.Id,
+ "name": db.Name,
+ "type": "database",
+ "status": utils.FindStatus(statuses.GetDatabases(), db.Id),
+ }
+
+ results = append(results, m)
+ }
+
+ j, err := json.Marshal(results)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func getMarkdownOutput(client qovery.APIClient, orgId string, projectId string, envId string, apps []qovery.Application, containers []qovery.ContainerResponse, jobs []qovery.JobResponse, databases []qovery.Database) string {
+ env, _, err := client.EnvironmentMainCallsAPI.GetEnvironment(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ header := fmt.Sprintf(`[](https://www.qovery.com)
+---
+
+Here is the [%s](%s) environment services.
+
+Click on the links below to access the different services:
+`, env.Name, fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s", orgId, projectId, envId))
+
+ body := `
+| Service | Logs | Preview URL |
+|---------|------|-------------|`
+
+ footer := `
+---
+
+Powered by [Qovery](https://qovery.com).`
+
+ na := "N/A"
+ for _, app := range apps {
+ previewUrl := getApplicationPreviewUrl(client, app.Id)
+ if previewUrl != nil {
+ p := fmt.Sprintf("[Link](%s)", *previewUrl)
+ previewUrl = &p
+ } else {
+ previewUrl = &na
+ }
+
+ consoleLink := fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/application/%s", orgId, projectId, envId, app.Id)
+ consoleLogsLink := fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/logs/%s/live-logs", orgId, projectId, envId, app.Id)
+ body += fmt.Sprintf("\n| [%s](%s) | [Show logs](%s) | %s |", app.Name, consoleLink, consoleLogsLink, *previewUrl)
+ }
+
+ for _, container := range containers {
+ previewUrl := getContainerPreviewUrl(client, container.Id)
+ if previewUrl != nil {
+ p := fmt.Sprintf("[Link](%s)", *previewUrl)
+ previewUrl = &p
+ } else {
+ previewUrl = &na
+ }
+
+ consoleLink := fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/application/%s", orgId, projectId, envId, container.Id)
+ consoleLogsLink := fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/logs/%s/live-logs", orgId, projectId, envId, container.Id)
+ body += fmt.Sprintf("\n| [%s](%s) | [Show logs](%s) | %s |", container.Name, consoleLink, consoleLogsLink, *previewUrl)
+ }
+
+ for _, job := range jobs {
+ consoleLink := fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/application/%s", orgId, projectId, envId, utils.GetJobId(&job))
+ consoleLogsLink := fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/logs/%s/live-logs", orgId, projectId, envId, utils.GetJobId(&job))
+ body += fmt.Sprintf("\n| [%s](%s) | [Show logs](%s) | %s |", utils.GetJobName(&job), consoleLink, consoleLogsLink, na)
+ }
+
+ for _, db := range databases {
+ consoleLink := fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/database/%s", orgId, projectId, envId, db.Id)
+ consoleLogsLink := fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/logs/%s/deployment-logs", orgId, projectId, envId, db.Id)
+ body += fmt.Sprintf("\n| [%s](%s) | [Show logs](%s) | %s |", db.Name, consoleLink, consoleLogsLink, na)
+ }
+
+ return header + body + footer
+}
+
+func getJiraOutput(client qovery.APIClient, orgId string, projectId string, envId string, apps []qovery.Application, containers []qovery.ContainerResponse, jobs []qovery.JobResponse, databases []qovery.Database) string {
+ env, _, err := client.EnvironmentMainCallsAPI.GetEnvironment(context.Background(), envId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ header := fmt.Sprintf(`[Qovery Preview|%s]
+---
+
+Here is the [%s|%s] environment services.
+
+Click on the links below to access the different services:
+`, fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s", orgId, projectId, envId), env.Name, fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s", orgId, projectId, envId))
+
+ body := `
+|| Service || Logs || Preview URL ||`
+
+ footer := `
+---
+
+Powered by [Qovery|https://qovery.com].`
+
+ na := "N/A"
+ for _, app := range apps {
+ previewUrl := getApplicationPreviewUrl(client, app.Id)
+ if previewUrl != nil {
+ p := fmt.Sprintf("[Link|%s]", *previewUrl)
+ previewUrl = &p
+ } else {
+ previewUrl = &na
+ }
+
+ consoleLink := fmt.Sprintf("[Console|%s]", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/application/%s", orgId, projectId, envId, app.Id))
+ consoleLogsLink := fmt.Sprintf("[Logs|%s]", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/logs/%s/live-logs", orgId, projectId, envId, app.Id))
+ body += fmt.Sprintf("\n|| [%s|%s] || %s || %s ||", app.Name, consoleLink, consoleLogsLink, *previewUrl)
+ }
+
+ for _, container := range containers {
+ previewUrl := getContainerPreviewUrl(client, container.Id)
+ if previewUrl != nil {
+ p := fmt.Sprintf("[Link|%s]", *previewUrl)
+ previewUrl = &p
+ } else {
+ previewUrl = &na
+ }
+
+ consoleLink := fmt.Sprintf("[Console|%s]", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/application/%s", orgId, projectId, envId, container.Id))
+ consoleLogsLink := fmt.Sprintf("[Logs|%s]", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/logs/%s/live-logs", orgId, projectId, envId, container.Id))
+ body += fmt.Sprintf("\n|| [%s|%s] || %s || %s ||", container.Name, consoleLink, consoleLogsLink, *previewUrl)
+ }
+
+ for _, job := range jobs {
+ consoleLink := fmt.Sprintf("[Console|%s]", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/application/%s", orgId, projectId, envId, utils.GetJobId(&job)))
+ consoleLogsLink := fmt.Sprintf("[Logs|%s]", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/logs/%s/live-logs", orgId, projectId, envId, utils.GetJobId(&job)))
+ body += fmt.Sprintf("\n|| [%s|%s] || %s || %s ||", utils.GetJobName(&job), consoleLink, consoleLogsLink, na)
+ }
+
+ for _, db := range databases {
+ consoleLink := fmt.Sprintf("[Console|%s]", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/database/%s", orgId, projectId, envId, db.Id))
+ consoleLogsLink := fmt.Sprintf("[Logs|%s]", fmt.Sprintf("https://console.qovery.com/organization/%s/project/%s/environment/%s/logs/%s/deployment-logs", orgId, projectId, envId, db.Id))
+ body += fmt.Sprintf("\n|| [%s|%s] || %s || %s ||", db.Name, consoleLink, consoleLogsLink, na)
+ }
+
+ return header + body + footer
+}
+
+func getApplicationPreviewUrl(client qovery.APIClient, appId string) *string {
+ links, _, err := client.ApplicationMainCallsAPI.ListApplicationLinks(context.Background(), appId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ for _, link := range links.GetResults() {
+ return &link.Url
+ }
+
+ return nil
+}
+
+func getContainerPreviewUrl(client qovery.APIClient, containerId string) *string {
+ links, _, err := client.ContainerMainCallsAPI.ListContainerLinks(context.Background(), containerId).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ for _, link := range links.GetResults() {
+ return &link.Url
+ }
+
+ return nil
+}
+
+func init() {
+ serviceCmd.AddCommand(serviceListCmd)
+ serviceListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ serviceListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ serviceListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ serviceListCmd.Flags().BoolVarP(&markdownFlag, "markdown", "", false, "Markdown output")
+ serviceListCmd.Flags().BoolVarP(&jiraFlag, "jira", "", false, "Atlassian Jira output")
+ serviceListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/service_list_pods.go b/cmd/service_list_pods.go
new file mode 100644
index 00000000..7a75a192
--- /dev/null
+++ b/cmd/service_list_pods.go
@@ -0,0 +1,52 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/pkg"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+var serviceListPods = &cobra.Command{
+ Use: "list-pods",
+ Short: "List the pods of a service with their pods",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ var portForwardRequest *pkg.PortForwardRequest
+ var err error
+ if len(args) > 0 {
+ portForwardRequest, err = portForwardRequestWithApplicationUrl(args)
+ } else {
+ portForwardRequest, err = portForwardRequestWithoutArg()
+ }
+ if err != nil {
+ utils.PrintlnError(err)
+ return
+ }
+
+ pods, err := pkg.ExecListPods(portForwardRequest)
+ if err != nil {
+ utils.PrintlnError(err)
+ return
+ }
+
+ var data [][]string
+ for _, pod := range pods.Pods {
+ sort.Slice(pod.Ports, func(i, j int) bool { return pod.Ports[i] < pod.Ports[j] })
+ ports := make([]string, len(pod.Ports))
+ for i, x := range pod.Ports {
+ ports[i] = strconv.FormatUint(uint64(x), 10)
+ }
+ data = append(data, []string{pod.Name, strings.Join(ports, ", ")})
+ }
+ _ = utils.PrintTable([]string{"Pod Name", "Ports"}, data)
+ },
+}
+
+func init() {
+ var serviceListPodsCmd = serviceListPods
+ rootCmd.AddCommand(serviceListPodsCmd)
+}
diff --git a/cmd/shell.go b/cmd/shell.go
index 29f72212..e1d64ebe 100644
--- a/cmd/shell.go
+++ b/cmd/shell.go
@@ -3,12 +3,16 @@ package cmd
import (
"errors"
"fmt"
+ "os"
+ "strings"
+ "context"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
"github.com/qovery/qovery-cli/pkg"
+ "github.com/qovery/qovery-cli/pkg/usercontext"
"github.com/qovery/qovery-cli/utils"
- "github.com/qovery/qovery-client-go"
- "github.com/spf13/cobra"
- "golang.org/x/net/context"
)
var shellCmd = &cobra.Command{
@@ -16,49 +20,166 @@ var shellCmd = &cobra.Command{
Short: "Connect to an application container",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)
- useContext := false
- currentContext, err := utils.CurrentContext()
- if err != nil {
- utils.PrintlnError(err)
- return
- }
- utils.PrintlnInfo("Current context:")
- if currentContext.ApplicationId != "" && currentContext.ApplicationName != "" &&
- currentContext.EnvironmentId != "" && currentContext.EnvironmentName != "" &&
- currentContext.ProjectId != "" && currentContext.ProjectName != "" &&
- currentContext.OrganizationId != "" && currentContext.OrganizationName != "" {
- if err := utils.PrintlnContext(); err != nil {
- fmt.Println("Context not yet configured.")
+ var shellRequest *pkg.ShellRequest
+ var err error
+ if strings.TrimSpace(organizationName) != "" || strings.TrimSpace(projectName) != "" || strings.TrimSpace(environmentName) != "" || strings.TrimSpace(serviceName) != "" {
+ if strings.TrimSpace(organizationName) == "" {
+ utils.PrintlnError(errors.New("organization name is required"))
+ return
}
- fmt.Println()
-
- utils.PrintlnInfo("Continue with shell command using this context ?")
- useContext = utils.Validate("context")
- fmt.Println()
- } else {
- if err := utils.PrintlnContext(); err != nil {
- fmt.Println("Context not yet configured.")
- fmt.Println("Unable to use current context for `shell` command.")
- fmt.Println()
+ if strings.TrimSpace(projectName) == "" {
+ utils.PrintlnError(errors.New("project name is required"))
+ return
+ }
+ if strings.TrimSpace(environmentName) == "" {
+ utils.PrintlnError(errors.New("environment name is required"))
+ return
+ }
+ if strings.TrimSpace(serviceName) == "" {
+ utils.PrintlnError(errors.New("service name is required"))
+ return
}
- }
- var req *pkg.ShellRequest
- if useContext {
- req, err = shellRequestFromContext(currentContext)
+ shellRequest, err = shellRequestWithContextFlags()
+ } else if len(args) == 1 {
+ shellRequest, err = shellRequestWithApplicationUrl(args)
} else {
- req, err = shellRequestFromSelect()
+ shellRequest, err = shellRequestWithoutArg()
}
if err != nil {
utils.PrintlnError(err)
return
}
- pkg.ExecShell(req)
+ endpoint := "/shell/exec"
+ if ephemeral {
+ if ephemeralMode != "clone" && ephemeralMode != "debug" {
+ utils.PrintlnError(errors.New("--mode must be 'clone' or 'debug'"))
+ return
+ }
+ if ephemeralMode == "debug" && (cpuOverride != "" || memoryOverride != "") {
+ utils.PrintlnInfo("--cpu/--memory only apply to --mode clone; ignoring them in debug mode.")
+ }
+ shellRequest.EphemeralMode = ephemeralMode
+ shellRequest.CpuOverride = cpuOverride
+ shellRequest.MemoryOverride = memoryOverride
+ endpoint = "/shell/ephemeral"
+ } else if cmd.Flags().Changed("mode") {
+ utils.PrintlnInfo("--mode has no effect without --ephemeral; ignoring it.")
+ }
+ pkg.ExecShell(shellRequest, endpoint)
},
}
+var (
+ command []string
+ podName string
+ podContainerName string
+ ephemeral bool
+ ephemeralMode string
+ cpuOverride string
+ memoryOverride string
+)
+
+func shellRequestWithContextFlags() (*pkg.ShellRequest, error) {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationID, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ projectID, err := getProjectContextResourceId(client, projectName, organizationID)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ environmentID, err := getEnvironmentContextResourceId(client, environmentName, projectID)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ environment, err := utils.GetEnvironmentById(environmentID)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ service, err := getServiceContextResourceId(client, serviceName, environmentID)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return &pkg.ShellRequest{
+ ServiceID: utils.Id(service.ID),
+ ProjectID: utils.Id(projectID),
+ OrganizationID: utils.Id(organizationID),
+ EnvironmentID: utils.Id(environmentID),
+ ClusterID: environment.ClusterID,
+ PodName: podName,
+ ContainerName: podContainerName,
+ Command: command,
+ }, nil
+}
+
+func shellRequestWithoutArg() (*pkg.ShellRequest, error) {
+ useContext := false
+ currentContext, err := utils.GetCurrentContext()
+ if err != nil {
+ return nil, err
+ }
+
+ utils.PrintlnInfo("Current context:")
+ if currentContext.ServiceId != "" && currentContext.ServiceName != "" &&
+ currentContext.EnvironmentId != "" && currentContext.EnvironmentName != "" &&
+ currentContext.ProjectId != "" && currentContext.ProjectName != "" &&
+ currentContext.OrganizationId != "" && currentContext.OrganizationName != "" {
+ if err := utils.PrintContext(); err != nil {
+ fmt.Println("Context not yet configured.")
+ }
+ fmt.Println()
+
+ utils.PrintlnInfo("Continue with shell command using this context ?")
+ useContext = utils.Validate("context")
+ fmt.Println()
+ } else {
+ if err := utils.PrintContext(); err != nil {
+ fmt.Println("Context not yet configured.")
+ fmt.Println("Unable to use current context for `shell` command.")
+ fmt.Println()
+ }
+ }
+
+ var req *pkg.ShellRequest
+ if useContext {
+ req, err = shellRequestFromContext(currentContext)
+ } else {
+ req, err = shellRequestFromSelect()
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return req, nil
+}
+
func shellRequestFromSelect() (*pkg.ShellRequest, error) {
utils.PrintlnInfo("Select organization")
orga, err := utils.SelectOrganization()
@@ -78,31 +199,35 @@ func shellRequestFromSelect() (*pkg.ShellRequest, error) {
return nil, err
}
- utils.PrintlnInfo("Select application")
- app, err := utils.SelectApplication(env.ID)
+ utils.PrintlnInfo("Select service")
+ service, err := utils.SelectService(env.ID)
if err != nil {
return nil, err
}
return &pkg.ShellRequest{
- ApplicationID: app.ID,
+ ServiceID: service.ID,
ProjectID: project.ID,
OrganizationID: orga.ID,
EnvironmentID: env.ID,
ClusterID: env.ClusterID,
+ PodName: podName,
+ ContainerName: podContainerName,
+ Command: command,
}, nil
}
func shellRequestFromContext(currentContext utils.QoveryContext) (*pkg.ShellRequest, error) {
- token, err := utils.GetAccessToken()
+ tokenType, token, err := utils.GetAccessToken(false)
if err != nil {
- return nil, err
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
}
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
+ client := utils.GetQoveryClient(tokenType, token)
- e, res, err := client.EnvironmentMainCallsApi.GetEnvironment(auth, string(currentContext.EnvironmentId)).Execute()
+ e, res, err := client.EnvironmentMainCallsAPI.GetEnvironment(context.Background(), string(currentContext.EnvironmentId)).Execute()
if err != nil {
return nil, err
}
@@ -111,14 +236,148 @@ func shellRequestFromContext(currentContext utils.QoveryContext) (*pkg.ShellRequ
}
return &pkg.ShellRequest{
- ApplicationID: currentContext.ApplicationId,
+ ServiceID: currentContext.ServiceId,
ProjectID: currentContext.ProjectId,
OrganizationID: currentContext.OrganizationId,
EnvironmentID: currentContext.EnvironmentId,
ClusterID: utils.Id(e.ClusterId),
+ PodName: podName,
+ ContainerName: podContainerName,
+ Command: command,
+ }, nil
+}
+
+func shellRequestWithApplicationUrl(args []string) (*pkg.ShellRequest, error) {
+ url := args[0]
+ url = strings.Replace(url, "https://console.qovery.com/", "", 1)
+ url = strings.Replace(url, "https://new.console.qovery.com/", "", 1)
+ urlSplit := strings.Split(url, "/")
+
+ if len(urlSplit) < 8 {
+ return nil, errors.New("Wrong URL format: " + url)
+ }
+
+ organizationId := urlSplit[1]
+ organization, err := utils.GetOrganizationById(organizationId)
+ if err != nil {
+ return nil, err
+ }
+
+ projectId := urlSplit[3]
+ project, err := utils.GetProjectById(projectId)
+ if err != nil {
+ return nil, err
+ }
+
+ environmentId := urlSplit[5]
+ environment, err := utils.GetEnvironmentById(environmentId)
+ if err != nil {
+ return nil, err
+ }
+
+ environmentServices, err := utils.GetEnvironmentServicesById(environmentId)
+ if err != nil {
+ return nil, err
+ }
+
+ var service utils.Service
+ serviceId := urlSplit[7]
+ for _, envService := range environmentServices {
+ if envService.ID == serviceId {
+ switch envService.Type {
+
+ case utils.ApplicationType:
+ applicationAPI, err := utils.GetApplicationById(serviceId)
+ if err != nil {
+ return nil, err
+ }
+ service = utils.Service{
+ ID: applicationAPI.ID,
+ Name: applicationAPI.Name,
+ Type: utils.ApplicationType,
+ }
+
+ case utils.ContainerType:
+ containerAPI, err := utils.GetContainerById(serviceId)
+ if err != nil {
+ return nil, err
+ }
+ service = utils.Service{
+ ID: containerAPI.ID,
+ Name: containerAPI.Name,
+ Type: utils.ContainerType,
+ }
+
+ case utils.JobType:
+ jobAPI, err := utils.GetJobById(serviceId)
+ if err != nil {
+ return nil, err
+ }
+ service = utils.Service{
+ ID: jobAPI.ID,
+ Name: jobAPI.Name,
+ Type: utils.JobType,
+ }
+
+ case utils.DatabaseType:
+ db, err := utils.GetDatabaseById(serviceId)
+ if err != nil {
+ return nil, err
+ }
+ service = *db
+
+ case utils.HelmType:
+ helm, err := utils.GetHelmById(serviceId)
+ if err != nil {
+ return nil, err
+ }
+ service = *helm
+
+ default:
+ return nil, errors.New("ServiceLevel type `" + string(envService.Type) + "` is not supported for shell")
+ }
+ }
+ }
+
+ _ = pterm.DefaultTable.WithData(pterm.TableData{
+ {"Organization", string(organization.Name)},
+ {"Project", string(project.Name)},
+ {"Environment", string(environment.Name)},
+ {"ServiceLevel", string(service.Name)},
+ {"ServiceType", string(service.Type)},
+ }).Render()
+
+ return &pkg.ShellRequest{
+ OrganizationID: organization.ID,
+ ProjectID: project.ID,
+ EnvironmentID: environment.ID,
+ ServiceID: service.ID,
+ ClusterID: environment.ClusterID,
+ PodName: podName,
+ ContainerName: podContainerName,
+ Command: command,
}, nil
}
func init() {
+ shellCmd := shellCmd
+ shellCmd.Flags().StringSliceVarP(&command, "command", "c", []string{"sh"}, "command to launch inside the pod")
+ shellCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ shellCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ shellCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ shellCmd.Flags().StringVarP(&serviceName, "service", "", "", "Service Name")
+ shellCmd.Flags().StringVarP(&podName, "pod", "p", "", "pod name where to exec into")
+ shellCmd.Flags().StringVar(&podContainerName, "container", "", "container name inside the pod")
+ shellCmd.Flags().BoolVar(&ephemeral, "ephemeral", false, "spawn an ephemeral shell instead of connecting to an existing pod")
+ shellCmd.Flags().StringVar(&ephemeralMode, "mode", "clone", "ephemeral mode: 'clone' (new isolated pod, Heroku-style) or 'debug' (ephemeral container injected into existing pod, kubectl-debug style)")
+ shellCmd.Flags().StringVar(&cpuOverride, "cpu", "", "override CPU request+limit for the ephemeral pod (e.g. '500m', '2')")
+ shellCmd.Flags().StringVar(&memoryOverride, "memory", "", "override memory request+limit for the ephemeral pod (e.g. '512Mi', '2Gi')")
+ shellCmd.Example = "qovery shell\n" +
+ "qovery shell \n" +
+ "qovery shell --organization --project --environment --service \n" +
+ "qovery shell --ephemeral --mode clone --organization --project --environment --service \n" +
+ "qovery shell --ephemeral --mode clone --memory 2Gi --organization --project --environment --service \n" +
+ "qovery shell --ephemeral --mode debug --organization --project --environment --service "
+
rootCmd.AddCommand(shellCmd)
}
diff --git a/cmd/status.go b/cmd/status.go
index 23f68123..ed1bf53e 100644
--- a/cmd/status.go
+++ b/cmd/status.go
@@ -1,12 +1,11 @@
package cmd
import (
+ "context"
"errors"
"github.com/pterm/pterm"
"github.com/qovery/qovery-cli/utils"
- "github.com/qovery/qovery-client-go"
"github.com/spf13/cobra"
- "golang.org/x/net/context"
"os"
)
@@ -15,34 +14,53 @@ var statusCmd = &cobra.Command{
Short: "Print the status of your application",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)
- token, err := utils.GetAccessToken()
+
+ tokenType, token, err := utils.GetAccessToken(false)
if err != nil {
utils.PrintlnError(err)
os.Exit(0)
}
- application, name, err := utils.CurrentApplication()
+ service, err := utils.CurrentService(true)
if err != nil {
utils.PrintlnError(err)
os.Exit(0)
}
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
+ client := utils.GetQoveryClient(tokenType, token)
- status, res, err := client.ApplicationMainCallsApi.GetApplicationStatus(auth, string(application)).Execute()
- if err != nil {
- utils.PrintlnError(err)
- os.Exit(0)
- }
- if res.StatusCode >= 400 {
- utils.PrintlnError(errors.New("Received " + res.Status + " response while listing organizations. "))
- }
+ switch service.Type {
+ case utils.ApplicationType:
+ status, res, err := client.ApplicationMainCallsAPI.GetApplicationStatus(context.Background(), string(service.ID)).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+ if res.StatusCode >= 400 {
+ utils.PrintlnError(errors.New("Received " + res.Status + " response while listing organizations. "))
+ }
- err = pterm.DefaultTable.WithData(pterm.TableData{{"Application", "Status"}, {string(name), status.State}}).Render()
- if err != nil {
- utils.PrintlnError(err)
- os.Exit(0)
+ err = pterm.DefaultTable.WithData(pterm.TableData{{"Application", "Status"}, {string(service.Name), string(status.State)}}).Render()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+ case utils.ContainerType:
+ status, res, err := client.ContainerMainCallsAPI.GetContainerStatus(context.Background(), string(service.ID)).Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+ if res.StatusCode >= 400 {
+ utils.PrintlnError(errors.New("Received " + res.Status + " response while listing organizations. "))
+ }
+
+ err = pterm.DefaultTable.WithData(pterm.TableData{{"Container", "Status"}, {string(service.Name), string(status.State)}}).Render()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
}
+
},
}
diff --git a/cmd/terraform.go b/cmd/terraform.go
new file mode 100644
index 00000000..1efc609a
--- /dev/null
+++ b/cmd/terraform.go
@@ -0,0 +1,28 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var terraformName string
+var terraformNames string
+var terraformCommitId string
+
+var terraformCmd = &cobra.Command{
+ Use: "terraform",
+ Short: "Manage terraform services",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(terraformCmd)
+}
diff --git a/cmd/terraform_delete.go b/cmd/terraform_delete.go
new file mode 100644
index 00000000..638add7a
--- /dev/null
+++ b/cmd/terraform_delete.go
@@ -0,0 +1,70 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var skipDestroyFlag bool
+var resourcesOnlyFlag bool
+
+var terraformDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete terraform resources",
+ Long: `Delete terraform resources and remove from Qovery.
+
+By default, this will execute 'terraform destroy' to delete all resources
+managed by this terraform service, then remove the service from Qovery.
+
+Use --skip-destroy to keep the infrastructure resources but remove the
+Qovery configuration. This is useful when you want to manage the resources
+outside of Qovery or import them into another system.
+
+Use --resources-only to delete the infrastructure resources but keep the
+Qovery configuration. This is useful when you want to clean up resources
+while keeping the terraform service definition in Qovery.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateTerraformArguments(terraformName, terraformNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ // Validate that skip-destroy and resources-only are mutually exclusive
+ if skipDestroyFlag && resourcesOnlyFlag {
+ utils.PrintlnError(fmt.Errorf("--skip-destroy and --resources-only flags are mutually exclusive"))
+ return
+ }
+
+ // delete terraform resources
+ terraformList := buildTerraformListFromTerraformNames(client, envId, terraformName, terraformNames)
+ err := utils.DeleteTerraforms(client, envId, terraformList, skipDestroyFlag, resourcesOnlyFlag)
+ utils.CheckError(err)
+
+ if skipDestroyFlag {
+ utils.Println(fmt.Sprintf("Request to remove terraform(s) %s from Qovery (keeping resources) has been queued..", pterm.FgBlue.Sprintf("%s%s", terraformName, terraformNames)))
+ } else if resourcesOnlyFlag {
+ utils.Println(fmt.Sprintf("Request to delete resources for terraform(s) %s (keeping Qovery configuration) has been queued..", pterm.FgBlue.Sprintf("%s%s", terraformName, terraformNames)))
+ } else {
+ utils.Println(fmt.Sprintf("Request to delete terraform(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", terraformName, terraformNames)))
+ }
+
+ WatchTerraformDeployment(client, envId, terraformList, watchFlag, qovery.STATEENUM_DELETED)
+ },
+}
+
+func init() {
+ terraformCmd.AddCommand(terraformDeleteCmd)
+ terraformDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ terraformDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ terraformDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ terraformDeleteCmd.Flags().StringVarP(&terraformName, "terraform", "n", "", "Terraform Name")
+ terraformDeleteCmd.Flags().StringVarP(&terraformNames, "terraforms", "", "", "Terraform Names (comma separated) Example: --terraforms \"tf1,tf2,tf3\"")
+ terraformDeleteCmd.Flags().BoolVarP(&skipDestroyFlag, "skip-destroy", "", false, "Skip terraform destroy (keep resources, only remove from Qovery)")
+ terraformDeleteCmd.Flags().BoolVarP(&resourcesOnlyFlag, "resources-only", "", false, "Delete resources only (keep Qovery configuration)")
+ terraformDeleteCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch terraform status until it's ready or an error occurs")
+}
diff --git a/cmd/terraform_external_secret.go b/cmd/terraform_external_secret.go
new file mode 100644
index 00000000..4f60dd55
--- /dev/null
+++ b/cmd/terraform_external_secret.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+)
+
+var terraformExternalSecretCmd = &cobra.Command{
+ Use: "external-secret",
+ Short: "Manage terraform external secrets",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ terraformCmd.AddCommand(terraformExternalSecretCmd)
+}
diff --git a/cmd/terraform_external_secret_create.go b/cmd/terraform_external_secret_create.go
new file mode 100644
index 00000000..23909b30
--- /dev/null
+++ b/cmd/terraform_external_secret_create.go
@@ -0,0 +1,88 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var terraformExternalSecretCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create terraform external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, projectId, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ terraforms, _, err := client.TerraformsAPI.ListTerraforms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ terraform := utils.FindByTerraformName(terraforms.GetResults(), terraformName)
+
+ if terraform == nil {
+ utils.PrintlnError(fmt.Errorf("terraform %s not found", terraformName))
+ utils.PrintlnInfo("You can list all terraforms with: qovery terraform list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.CreateServiceExternalSecret(client, projectId, envId, terraform.Id, utils.TerraformScope, utils.Key, utils.Reference, secretManagerAccessId, utils.MountPath)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been created", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ terraformExternalSecretCmd.AddCommand(terraformExternalSecretCreateCmd)
+ terraformExternalSecretCreateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ terraformExternalSecretCreateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ terraformExternalSecretCreateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ terraformExternalSecretCreateCmd.Flags().StringVarP(&terraformName, "terraform", "n", "", "Terraform Name")
+ terraformExternalSecretCreateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ terraformExternalSecretCreateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "Reference to the secret in the secrets provider")
+ terraformExternalSecretCreateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "Secret manager access name")
+ terraformExternalSecretCreateCmd.Flags().StringVarP(&utils.TerraformScope, "scope", "", "TERRAFORM", "Scope of this external secret ")
+ terraformExternalSecretCreateCmd.Flags().StringVarP(&utils.MountPath, "mount-path", "", "", "Path where the secret will be mounted as a file")
+
+ _ = terraformExternalSecretCreateCmd.MarkFlagRequired("key")
+ _ = terraformExternalSecretCreateCmd.MarkFlagRequired("reference")
+ _ = terraformExternalSecretCreateCmd.MarkFlagRequired("secret-manager-access-name")
+ _ = terraformExternalSecretCreateCmd.MarkFlagRequired("terraform")
+}
diff --git a/cmd/terraform_external_secret_delete.go b/cmd/terraform_external_secret_delete.go
new file mode 100644
index 00000000..afa97d48
--- /dev/null
+++ b/cmd/terraform_external_secret_delete.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var terraformExternalSecretDeleteCmd = &cobra.Command{
+ Use: "delete",
+ Short: "Delete terraform external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ terraforms, _, err := client.TerraformsAPI.ListTerraforms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ terraform := utils.FindByTerraformName(terraforms.GetResults(), terraformName)
+
+ if terraform == nil {
+ utils.PrintlnError(fmt.Errorf("terraform %s not found", terraformName))
+ utils.PrintlnInfo("You can list all terraforms with: qovery terraform list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.DeleteServiceVariable(client, terraform.Id, utils.TerraformType, utils.Key)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been deleted", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ terraformExternalSecretCmd.AddCommand(terraformExternalSecretDeleteCmd)
+ terraformExternalSecretDeleteCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ terraformExternalSecretDeleteCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ terraformExternalSecretDeleteCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ terraformExternalSecretDeleteCmd.Flags().StringVarP(&terraformName, "terraform", "n", "", "Terraform Name")
+ terraformExternalSecretDeleteCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+
+ _ = terraformExternalSecretDeleteCmd.MarkFlagRequired("key")
+ _ = terraformExternalSecretDeleteCmd.MarkFlagRequired("terraform")
+}
diff --git a/cmd/terraform_external_secret_update.go b/cmd/terraform_external_secret_update.go
new file mode 100644
index 00000000..21e681c3
--- /dev/null
+++ b/cmd/terraform_external_secret_update.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/pterm/pterm"
+ "github.com/spf13/cobra"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var terraformExternalSecretUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Update terraform external secret",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+ organizationId, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ terraforms, _, err := client.TerraformsAPI.ListTerraforms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ terraform := utils.FindByTerraformName(terraforms.GetResults(), terraformName)
+
+ if terraform == nil {
+ utils.PrintlnError(fmt.Errorf("terraform %s not found", terraformName))
+ utils.PrintlnInfo("You can list all terraforms with: qovery terraform list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ secretManagerAccessId, err := getSecretManagerAccessIdByName(client, organizationId, envId, utils.SecretManagerAccessName)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ err = utils.UpdateServiceExternalSecret(client, utils.Key, utils.Reference, secretManagerAccessId, terraform.Id, utils.TerraformType)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.Println(fmt.Sprintf("External secret %s has been updated", pterm.FgBlue.Sprintf("%s", utils.Key)))
+ },
+}
+
+func init() {
+ terraformExternalSecretCmd.AddCommand(terraformExternalSecretUpdateCmd)
+ terraformExternalSecretUpdateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ terraformExternalSecretUpdateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ terraformExternalSecretUpdateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ terraformExternalSecretUpdateCmd.Flags().StringVarP(&terraformName, "terraform", "n", "", "Terraform Name")
+ terraformExternalSecretUpdateCmd.Flags().StringVarP(&utils.Key, "key", "k", "", "External secret key")
+ terraformExternalSecretUpdateCmd.Flags().StringVarP(&utils.Reference, "reference", "r", "", "New reference to the secret in the secrets provider")
+ terraformExternalSecretUpdateCmd.Flags().StringVarP(&utils.SecretManagerAccessName, "secret-manager-access-name", "", "", "New secret manager access name")
+
+ _ = terraformExternalSecretUpdateCmd.MarkFlagRequired("key")
+ _ = terraformExternalSecretUpdateCmd.MarkFlagRequired("terraform")
+}
diff --git a/cmd/terraform_force_unlock.go b/cmd/terraform_force_unlock.go
new file mode 100644
index 00000000..5fee2445
--- /dev/null
+++ b/cmd/terraform_force_unlock.go
@@ -0,0 +1,46 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var terraformForceUnlockCmd = &cobra.Command{
+ Use: "force-unlock",
+ Short: "Force unlock terraform state file",
+ Long: `Force unlock terraform state file when it's stuck.
+
+This command will execute 'terraform force-unlock' on the specified terraform service(s).
+Use this when a state lock is preventing operations and you're certain no other
+operations are running.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateTerraformArguments(terraformName, terraformNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ // force unlock terraform state
+ terraformList := buildTerraformListFromTerraformNames(client, envId, terraformName, terraformNames)
+ action := "FORCE_UNLOCK"
+ err := utils.DeployTerraforms(client, envId, terraformList, terraformCommitId, &action)
+ utils.CheckError(err)
+ utils.Println(fmt.Sprintf("Request to force unlock terraform(s) %s state has been queued..", pterm.FgBlue.Sprintf("%s%s", terraformName, terraformNames)))
+ WatchTerraformDeployment(client, envId, terraformList, watchFlag, qovery.STATEENUM_DEPLOYED)
+ },
+}
+
+func init() {
+ terraformCmd.AddCommand(terraformForceUnlockCmd)
+ terraformForceUnlockCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ terraformForceUnlockCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ terraformForceUnlockCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ terraformForceUnlockCmd.Flags().StringVarP(&terraformName, "terraform", "n", "", "Terraform Name")
+ terraformForceUnlockCmd.Flags().StringVarP(&terraformNames, "terraforms", "", "", "Terraform Names (comma separated) Example: --terraforms \"tf1,tf2,tf3\"")
+ terraformForceUnlockCmd.Flags().StringVarP(&terraformCommitId, "commit-id", "c", "", "Git Commit ID (optional, defaults to deployed commit)")
+ terraformForceUnlockCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch terraform status until it's ready or an error occurs")
+}
diff --git a/cmd/terraform_list.go b/cmd/terraform_list.go
new file mode 100644
index 00000000..21df199b
--- /dev/null
+++ b/cmd/terraform_list.go
@@ -0,0 +1,110 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var terraformListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List terraform services",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ _, _, envId, err := getOrganizationProjectEnvironmentContextResourcesIds(client)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ terraforms, _, err := client.TerraformsAPI.ListTerraforms(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if jsonFlag {
+ fmt.Print(getTerraformJsonOutput(statuses.GetTerraforms(), terraforms.GetResults()))
+ return
+ }
+
+ var data [][]string
+
+ for _, terraform := range terraforms.GetResults() {
+ data = append(data, []string{
+ terraform.Id,
+ terraform.Name,
+ "Terraform",
+ utils.FindStatusTextWithColor(statuses.GetTerraforms(), terraform.Id),
+ terraform.UpdatedAt.String(),
+ })
+ }
+
+ err = utils.PrintTable([]string{"Id", "Name", "Type", "Status", "Last Update"}, data)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ },
+}
+
+func getTerraformJsonOutput(statuses []qovery.Status, terraforms []qovery.TerraformResponse) string {
+ var results []interface{}
+
+ for _, terraform := range terraforms {
+ results = append(results, map[string]interface{}{
+ "id": terraform.Id,
+ "name": terraform.Name,
+ "type": "Terraform",
+ "status": utils.FindStatus(statuses, terraform.Id),
+ "updated_at": utils.ToIso8601(terraform.UpdatedAt),
+ })
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
+
+func init() {
+ terraformCmd.AddCommand(terraformListCmd)
+ terraformListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ terraformListCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ terraformListCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ terraformListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/terraform_migrate_state.go b/cmd/terraform_migrate_state.go
new file mode 100644
index 00000000..f4cd660a
--- /dev/null
+++ b/cmd/terraform_migrate_state.go
@@ -0,0 +1,49 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var terraformMigrateStateCmd = &cobra.Command{
+ Use: "migrate-state",
+ Short: "Migrate terraform state to new backend",
+ Long: `Migrate terraform state to a new backend configuration.
+
+This command will execute 'terraform init -migrate-state' on the specified
+terraform service(s). Use this when changing backend configuration (e.g.,
+moving from local state to S3, or changing S3 bucket).
+
+Make sure to update your terraform backend configuration before running
+this command.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateTerraformArguments(terraformName, terraformNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ // migrate terraform state
+ terraformList := buildTerraformListFromTerraformNames(client, envId, terraformName, terraformNames)
+ action := "MIGRATE_STATE"
+ err := utils.DeployTerraforms(client, envId, terraformList, terraformCommitId, &action)
+ utils.CheckError(err)
+ utils.Println(fmt.Sprintf("Request to migrate terraform(s) %s state has been queued..", pterm.FgBlue.Sprintf("%s%s", terraformName, terraformNames)))
+ WatchTerraformDeployment(client, envId, terraformList, watchFlag, qovery.STATEENUM_DEPLOYED)
+ },
+}
+
+func init() {
+ terraformCmd.AddCommand(terraformMigrateStateCmd)
+ terraformMigrateStateCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ terraformMigrateStateCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ terraformMigrateStateCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ terraformMigrateStateCmd.Flags().StringVarP(&terraformName, "terraform", "n", "", "Terraform Name")
+ terraformMigrateStateCmd.Flags().StringVarP(&terraformNames, "terraforms", "", "", "Terraform Names (comma separated) Example: --terraforms \"tf1,tf2,tf3\"")
+ terraformMigrateStateCmd.Flags().StringVarP(&terraformCommitId, "commit-id", "c", "", "Git Commit ID (optional, defaults to deployed commit)")
+ terraformMigrateStateCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch terraform status until it's ready or an error occurs")
+}
diff --git a/cmd/terraform_plan.go b/cmd/terraform_plan.go
new file mode 100644
index 00000000..e12da102
--- /dev/null
+++ b/cmd/terraform_plan.go
@@ -0,0 +1,41 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var terraformPlanCmd = &cobra.Command{
+ Use: "plan",
+ Short: "Run terraform plan (dry-run)",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateTerraformArguments(terraformName, terraformNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ // plan terraform
+ terraformList := buildTerraformListFromTerraformNames(client, envId, terraformName, terraformNames)
+ action := "PLAN"
+ err := utils.DeployTerraforms(client, envId, terraformList, terraformCommitId, &action)
+ utils.CheckError(err)
+ utils.Println(fmt.Sprintf("Request to plan terraform(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", terraformName, terraformNames)))
+ WatchTerraformDeployment(client, envId, terraformList, watchFlag, qovery.STATEENUM_DEPLOYED)
+ },
+}
+
+func init() {
+ terraformCmd.AddCommand(terraformPlanCmd)
+ terraformPlanCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ terraformPlanCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ terraformPlanCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ terraformPlanCmd.Flags().StringVarP(&terraformName, "terraform", "n", "", "Terraform Name")
+ terraformPlanCmd.Flags().StringVarP(&terraformNames, "terraforms", "", "", "Terraform Names (comma separated) Example: --terraforms \"tf1,tf2,tf3\"")
+ terraformPlanCmd.Flags().StringVarP(&terraformCommitId, "commit-id", "c", "", "Git Commit ID (optional, defaults to deployed commit)")
+ terraformPlanCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch terraform status until it's ready or an error occurs")
+}
diff --git a/cmd/terraform_plan_and_apply.go b/cmd/terraform_plan_and_apply.go
new file mode 100644
index 00000000..2630aec0
--- /dev/null
+++ b/cmd/terraform_plan_and_apply.go
@@ -0,0 +1,108 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var terraformPlanAndApplyCmd = &cobra.Command{
+ Use: "plan-and-apply",
+ Short: "Deploy terraform (plan and apply)",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ validateTerraformArguments(terraformName, terraformNames)
+ envId := getEnvironmentIdFromContextPanicInCaseOfError(client)
+
+ // deploy multiple terraforms
+ terraformList := buildTerraformListFromTerraformNames(client, envId, terraformName, terraformNames)
+ err := utils.DeployTerraforms(client, envId, terraformList, terraformCommitId, nil)
+ utils.CheckError(err)
+ utils.Println(fmt.Sprintf("Request to deploy terraform(s) %s has been queued..", pterm.FgBlue.Sprintf("%s%s", terraformName, terraformNames)))
+ WatchTerraformDeployment(client, envId, terraformList, watchFlag, qovery.STATEENUM_DEPLOYED)
+ },
+}
+
+func buildTerraformListFromTerraformNames(
+ client *qovery.APIClient,
+ environmentId string,
+ terraformName string,
+ terraformNames string,
+) []*qovery.TerraformResponse {
+ var terraformList []*qovery.TerraformResponse
+ terraforms, _, err := client.TerraformsAPI.ListTerraforms(context.Background(), environmentId).Execute()
+ utils.CheckError(err)
+
+ if terraformName != "" {
+ terraform := utils.FindByTerraformName(terraforms.GetResults(), terraformName)
+ if terraform == nil {
+ utils.PrintlnError(fmt.Errorf("terraform %s not found", terraformName))
+ utils.PrintlnInfo("You can list all terraforms with: qovery terraform list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ terraformList = append(terraformList, terraform)
+ }
+ if terraformNames != "" {
+ for _, name := range strings.Split(terraformNames, ",") {
+ trimmedName := strings.TrimSpace(name)
+ terraform := utils.FindByTerraformName(terraforms.GetResults(), trimmedName)
+ if terraform == nil {
+ utils.PrintlnError(fmt.Errorf("terraform %s not found", name))
+ utils.PrintlnInfo("You can list all terraforms with: qovery terraform list")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ terraformList = append(terraformList, terraform)
+ }
+ }
+
+ return terraformList
+}
+
+func validateTerraformArguments(terraformName string, terraformNames string) {
+ if terraformName == "" && terraformNames == "" {
+ utils.PrintlnError(fmt.Errorf("use either --terraform \"\" or --terraforms \", \" but not both at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ if terraformName != "" && terraformNames != "" {
+ utils.PrintlnError(fmt.Errorf("you can't use --terraform and --terraforms at the same time"))
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
+
+func WatchTerraformDeployment(
+ client *qovery.APIClient,
+ envId string,
+ terraforms []*qovery.TerraformResponse,
+ watchFlag bool,
+ finalServiceState qovery.StateEnum,
+) {
+ if watchFlag {
+ time.Sleep(3 * time.Second) // wait for the deployment request to be processed (prevent from race condition)
+ utils.WatchEnvironment(envId, finalServiceState, client)
+ }
+}
+
+func init() {
+ terraformCmd.AddCommand(terraformPlanAndApplyCmd)
+ terraformPlanAndApplyCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ terraformPlanAndApplyCmd.Flags().StringVarP(&projectName, "project", "", "", "Project Name")
+ terraformPlanAndApplyCmd.Flags().StringVarP(&environmentName, "environment", "", "", "Environment Name")
+ terraformPlanAndApplyCmd.Flags().StringVarP(&terraformName, "terraform", "n", "", "Terraform Name")
+ terraformPlanAndApplyCmd.Flags().StringVarP(&terraformNames, "terraforms", "", "", "Terraform Names (comma separated) Example: --terraforms \"tf1,tf2,tf3\"")
+ terraformPlanAndApplyCmd.Flags().StringVarP(&terraformCommitId, "commit-id", "c", "", "Git Commit ID (optional, defaults to deployed commit)")
+ terraformPlanAndApplyCmd.Flags().BoolVarP(&watchFlag, "watch", "w", false, "Watch terraform status until it's ready or an error occurs")
+}
diff --git a/cmd/terraform_setup_backend.go b/cmd/terraform_setup_backend.go
new file mode 100644
index 00000000..4e91ddf7
--- /dev/null
+++ b/cmd/terraform_setup_backend.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/tools/clientcmd"
+)
+
+var terraformId string
+var terraformSetupBackendCmd = &cobra.Command{
+ Use: "setup-backend",
+ Short: "Generate a Terraform backend configuration file that can be used to access your tf-state",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+
+ // Retrieve terraform service and its environment
+ terraform, _, err := client.TerraformMainCallsAPI.GetTerraform(context.Background(), terraformId).Execute()
+ checkError(err)
+ env, _, err := client.EnvironmentMainCallsAPI.GetEnvironment(context.Background(), terraform.Environment.Id).Execute()
+ checkError(err)
+ utils.Println(fmt.Sprintf("Preparing backend.tf file for terraform `%s` of environment `%s`", terraform.Name, env.Name))
+
+ // Download kubeconfig to connect to the cluster
+ kubeconfigPath := downloadKubeconfig(env.ClusterId, false)
+
+ // Create kubeclient to retrieve the namespace of the tfstate secret
+ kubeconfig, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath)
+ checkError(err)
+ kubeClient, err := kubernetes.NewForConfig(kubeconfig)
+ checkError(err)
+ secrets, err := kubeClient.CoreV1().Secrets("").List(context.Background(), v1.ListOptions{
+ LabelSelector: fmt.Sprintf("qovery.com/service-id=%s,tfstate=true", terraform.Id),
+ })
+ checkError(err)
+ if len(secrets.Items) == 0 {
+ log.Errorf("No tfstate secret found for terraform %s. The service must be deployed at least succesfully once", terraform.Id)
+ os.Exit(1)
+ }
+
+ // Generate backend.tf file
+ backendtf := fmt.Sprintf(`
+terraform {
+ backend "kubernetes" {
+ secret_suffix = "%s"
+ namespace = "%s"
+ config_path = "%s"
+ }
+}
+`, terraform.Id, secrets.Items[0].Namespace, kubeconfigPath)
+
+ utils.Println("Would you like to write `backend.tf` in current directory ?")
+ if !utils.Validate("") {
+ return
+ }
+
+ utils.Println("Writing `backend.tf` file in current directory")
+ err = os.WriteFile("backend.tf", []byte(backendtf), 0600)
+ checkError(err)
+ var commandName string
+ switch terraform.Engine {
+ case qovery.TERRAFORMENGINEENUM_TERRAFORM:
+ commandName = "terraform"
+ case qovery.TERRAFORMENGINEENUM_OPEN_TOFU:
+ commandName = "tofu"
+ }
+
+ utils.Println(fmt.Sprintf("You can now run `%s init` to initialize your project with your tf-state configured on your cluster", commandName))
+ },
+}
+
+func init() {
+ terraformCmd.AddCommand(terraformSetupBackendCmd)
+ terraformSetupBackendCmd.Flags().StringVarP(&terraformId, "terraform", "t", "", "Terraform UUID. If not provided, the CLI will use the service context")
+}
diff --git a/cmd/token.go b/cmd/token.go
index 7a244a96..ac0b55f7 100644
--- a/cmd/token.go
+++ b/cmd/token.go
@@ -1,14 +1,17 @@
package cmd
import (
+ "context"
"errors"
"github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
"github.com/spf13/cobra"
- "io/ioutil"
- "net/http"
- "strings"
)
+type TokenCreationResponseDto struct {
+ Token string
+}
+
var tokenCmd = &cobra.Command{
Use: "token",
Short: "Generate an API token",
@@ -16,13 +19,13 @@ var tokenCmd = &cobra.Command{
utils.Capture(cmd)
utils.PrintlnInfo("Select organization")
- organization, err := utils.SelectOrganization()
+ tokenInformation, err := utils.SelectTokenInformation()
if err != nil {
utils.PrintlnError(err)
return
}
- token, err := generateMachineToMachineAPIToken(organization)
+ token, err := generateMachineToMachineAPIToken(tokenInformation)
if err != nil {
utils.PrintlnError(err)
@@ -35,32 +38,32 @@ var tokenCmd = &cobra.Command{
},
}
-func generateMachineToMachineAPIToken(organization *utils.Organization) (string, error) {
- token, err := utils.GetAccessToken()
+func generateMachineToMachineAPIToken(tokenInformation *utils.TokenInformation) (string, error) {
+ tokenType, token, err := utils.GetAccessToken(false)
if err != nil {
return "", err
}
- // apiToken endpoint is not yet exposed in the OpenAPI spec at the moment. It's planned officially for Q3 2022
- req, err := http.NewRequest(http.MethodPost, string("https://api.qovery.com/organization/"+organization.ID+"/apiToken"), nil)
- if err != nil {
- return "", err
- }
+ roleId := qovery.NullableString{}
+ roleId.Set(&tokenInformation.Role.ID)
- req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token)))
- req.Header.Set("Content-Type", "application/json")
+ req := qovery.OrganizationApiTokenCreateRequest{
+ Name: tokenInformation.Name,
+ Description: &tokenInformation.Description,
+ Scope: qovery.NullableOrganizationApiTokenScope{},
+ RoleId: roleId,
+ }
- res, err := http.DefaultClient.Do(req)
+ client := utils.GetQoveryClient(tokenType, token)
+ createdToken, res, err := client.OrganizationApiTokenAPI.CreateOrganizationApiToken(context.Background(), string(tokenInformation.Organization.ID)).OrganizationApiTokenCreateRequest(req).Execute()
if err != nil {
return "", err
}
-
if res.StatusCode >= 400 {
return "", errors.New("Received " + res.Status + " response while fetching environment. ")
}
- result, _ := ioutil.ReadAll(res.Body)
- return string(result), nil
+ return *createdToken.Token, nil
}
func init() {
diff --git a/cmd/upgrade.go b/cmd/upgrade.go
index 0bbb26a3..49f03958 100644
--- a/cmd/upgrade.go
+++ b/cmd/upgrade.go
@@ -1,24 +1,25 @@
//go:build !windows
-// +build !windows
package cmd
import (
+ "context"
"fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+
"github.com/kardianos/osext"
- "github.com/mholt/archiver/v3"
+ "github.com/mholt/archives"
"github.com/qovery/qovery-cli/pkg"
"github.com/qovery/qovery-cli/utils"
"github.com/spf13/cobra"
"golang.org/x/sys/unix"
- "net/http"
- "os"
- "os/exec"
- "runtime"
)
-import iio "io"
-
var upgradeCmd = &cobra.Command{
Use: "upgrade",
Short: "Upgrade Qovery CLI to latest version",
@@ -27,12 +28,11 @@ var upgradeCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
currentBinaryFilename, _ := osext.Executable()
filename := "qovery"
- archivePath := "/tmp/"
+ tempDir := os.TempDir()
archiveName := filename + ".tgz"
- archivePathName := archivePath + archiveName
- uncompressPath := "/tmp/" + filename + "/"
- uncompressQoveryBinaryPath := uncompressPath + filename
- cleanList := []string{uncompressPath, archivePathName}
+ archivePathName := filepath.Join(tempDir, archiveName)
+ uncompressQoveryBinaryPath := filepath.Join(tempDir, filename)
+ cleanList := []string{archivePathName, uncompressQoveryBinaryPath}
available, message, desiredVersion := pkg.CheckAvailableNewVersion()
if !available {
@@ -40,50 +40,88 @@ var upgradeCmd = &cobra.Command{
os.Exit(0)
}
- url := fmt.Sprintf("https://github.com/Qovery/qovery-cli/releases/download/v%s/qovery-cli_%s_%s_%s.tar.gz",
- desiredVersion, desiredVersion, runtime.GOOS, runtime.GOARCH)
+ urlFilename := fmt.Sprintf("qovery-cli_%s_%s_%s.tar.gz", desiredVersion, runtime.GOOS, runtime.GOARCH)
+ url := fmt.Sprintf("https://github.com/Qovery/qovery-cli/releases/download/v%s/%s", desiredVersion, urlFilename)
binaryWriteAccess := unix.Access(currentBinaryFilename, unix.W_OK)
if binaryWriteAccess != nil {
- utils.PrintlnError(fmt.Errorf("Upgrade cancelled: no write permission on the Qovery CLI binary file: %s", currentBinaryFilename))
+ utils.PrintlnError(fmt.Errorf("upgrade cancelled: no write permission on the Qovery CLI binary file: %s", currentBinaryFilename))
cleanArchives(cleanList)
os.Exit(0)
}
resp, err := http.Get(url)
if err != nil {
- utils.PrintlnError(fmt.Errorf("Error while downloading the latest version: %s", err))
+ utils.PrintlnError(fmt.Errorf("error while downloading the latest version: %s", err))
os.Exit(0)
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ utils.PrintlnError(fmt.Errorf("error while closing response body: %s", err))
+ }
+ }()
out, err := os.Create(archivePathName)
if err != nil {
- utils.PrintlnError(fmt.Errorf("Error while overriding Qovery CLI binary file: %s", err))
+ utils.PrintlnError(fmt.Errorf("error while overriding Qovery CLI binary file: %s", err))
os.Exit(0)
}
- defer out.Close()
+ defer func() {
+ if err := out.Close(); err != nil {
+ utils.PrintlnError(fmt.Errorf("error while closing output file: %s", err))
+ }
+ }()
- _, err = iio.Copy(out, resp.Body)
+ // Decompress the tar.gz and extract the cli
+ format, stream, err := archives.Identify(context.Background(), urlFilename, resp.Body)
if err != nil {
- utils.PrintlnError(fmt.Errorf("Error while adding content to Qovery CLI binary file: %s", err))
+ utils.PrintlnError(fmt.Errorf("cannot identify archive format: %s", err))
os.Exit(0)
}
- if _, err := os.Stat(uncompressPath); !os.IsNotExist(err) {
- os.RemoveAll(uncompressPath)
+ if ex, ok := format.(archives.Extractor); ok {
+
+ // function that will be called for every file inside the archive.
+ // archives.FileInfo is going to contain the file info inside the archive
+ err = ex.Extract(context.Background(), stream, func(ctx context.Context, f archives.FileInfo) error {
+ if f.NameInArchive != "qovery" {
+ return nil
+ }
+
+ // Extract the cli from the archive on disk
+ cliFileInsideArchive, _ := f.Open()
+ defer func() {
+ if err := cliFileInsideArchive.Close(); err != nil {
+ utils.PrintlnError(fmt.Errorf("error while closing archive file: %s", err))
+ }
+ }()
+
+ cliFileOnFS, _ := os.Create(uncompressQoveryBinaryPath)
+ defer func() {
+ if err := cliFileOnFS.Close(); err != nil {
+ utils.PrintlnError(fmt.Errorf("error while closing filesystem file: %s", err))
+ }
+ }()
+
+ _, err = io.Copy(cliFileOnFS, cliFileInsideArchive)
+ if err != nil {
+ utils.PrintlnError(fmt.Errorf("error while uncompressing the cli on disk: %s", err))
+ os.Exit(0)
+ }
+
+ _ = cliFileOnFS.Chmod(0555)
+ return nil
+ })
}
- err = archiver.Unarchive(archivePathName, uncompressPath)
if err != nil {
- utils.PrintlnError(fmt.Errorf("Error while uncompressing the archive: %s", err))
+ utils.PrintlnError(fmt.Errorf("error while uncompressing the archive: %s", err))
os.Exit(0)
}
- // Fork to avoid override issue on a a running program
+ // Fork to avoid override issue on a running program
utils.PrintlnInfo(fmt.Sprintf("\nUpgrading Qovery CLI to version %s\n", desiredVersion))
- command := exec.Command("/bin/sh", "-c", "sleep 1 ; mv "+uncompressQoveryBinaryPath+" "+
- currentBinaryFilename)
+ command := exec.Command("/bin/sh", "-c", "mv "+uncompressQoveryBinaryPath+" "+currentBinaryFilename)
err = command.Start()
if err != nil {
utils.PrintlnError(err)
@@ -98,9 +136,9 @@ func init() {
func cleanArchives(listToRemove []string) {
for _, value := range listToRemove {
- err := os.RemoveAll(value)
- if err != nil {
- utils.PrintlnError(fmt.Errorf("Error while removing the element: %s", err))
+ err := os.Remove(value)
+ if err != nil && !os.IsNotExist(err) {
+ utils.PrintlnError(fmt.Errorf("error while removing the element: %s", err))
os.Exit(0)
}
}
diff --git a/cmd/version.go b/cmd/version.go
index c4b9f280..3deda6a2 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -2,6 +2,8 @@ package cmd
import (
"fmt"
+ "os"
+
"github.com/qovery/qovery-cli/pkg"
"github.com/qovery/qovery-cli/utils"
"github.com/spf13/cobra"
@@ -12,7 +14,14 @@ var versionCmd = &cobra.Command{
Short: "Print installed version of the Qovery CLI",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)
- utils.PrintlnInfo(fmt.Sprintf("%s\n", pkg.GetCurrentVersion()))
+ currentVersion, err := pkg.GetCurrentVersion()
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ utils.PrintlnInfo(fmt.Sprintf("%s\n", currentVersion))
},
}
diff --git a/cmd/webhook.go b/cmd/webhook.go
new file mode 100644
index 00000000..f58cb15e
--- /dev/null
+++ b/cmd/webhook.go
@@ -0,0 +1,26 @@
+package cmd
+
+import (
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var webhookId string
+
+var webhookCmd = &cobra.Command{
+ Use: "webhook",
+ Short: "Manage webhooks",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ if len(args) == 0 {
+ _ = cmd.Help()
+ os.Exit(0)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(webhookCmd)
+}
diff --git a/cmd/webhook_list.go b/cmd/webhook_list.go
new file mode 100644
index 00000000..3691dd01
--- /dev/null
+++ b/cmd/webhook_list.go
@@ -0,0 +1,140 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var webhookListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List webhooks",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ organizationId, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
+ checkError(err)
+
+ webhooks, _, err := client.OrganizationWebhookAPI.ListOrganizationWebHooks(context.Background(), organizationId).Execute()
+ checkError(err)
+
+ if jsonFlag {
+ utils.Println(getWebhookListJsonOutput(webhooks))
+ return
+ }
+
+ var data [][]string
+ for _, webhook := range webhooks.GetResults() {
+ kind := ""
+ if webhook.Kind != nil {
+ kind = string(*webhook.Kind)
+ }
+
+ targetUrl := ""
+ if webhook.TargetUrl != nil {
+ targetUrl = *webhook.TargetUrl
+ }
+
+ description := ""
+ if webhook.Description != nil {
+ description = *webhook.Description
+ }
+
+ enabled := "false"
+ if webhook.Enabled != nil && *webhook.Enabled {
+ enabled = "true"
+ }
+
+ events := ""
+ if len(webhook.Events) > 0 {
+ eventStrs := make([]string, len(webhook.Events))
+ for i, event := range webhook.Events {
+ eventStrs[i] = string(event)
+ }
+ events = strings.Join(eventStrs, ", ")
+ }
+
+ data = append(data, []string{
+ webhook.Id,
+ description,
+ kind,
+ targetUrl,
+ enabled,
+ events,
+ })
+ }
+
+ err = utils.PrintTable([]string{"ID", "Description", "Kind", "Target URL", "Enabled", "Events"}, data)
+ checkError(err)
+ },
+}
+
+func getWebhookListJsonOutput(webhooks *qovery.OrganizationWebhookResponseList) string {
+ var results []interface{}
+
+ for _, webhook := range webhooks.GetResults() {
+ webhookMap := map[string]interface{}{
+ "id": webhook.Id,
+ "created_at": webhook.CreatedAt.String(),
+ }
+
+ if webhook.UpdatedAt != nil {
+ webhookMap["updated_at"] = webhook.UpdatedAt.String()
+ }
+
+ if webhook.Description != nil {
+ webhookMap["description"] = *webhook.Description
+ }
+
+ if webhook.Kind != nil {
+ webhookMap["kind"] = string(*webhook.Kind)
+ }
+
+ if webhook.TargetUrl != nil {
+ webhookMap["target_url"] = *webhook.TargetUrl
+ }
+
+ if webhook.Enabled != nil {
+ webhookMap["enabled"] = *webhook.Enabled
+ }
+
+ if len(webhook.Events) > 0 {
+ events := make([]string, len(webhook.Events))
+ for i, event := range webhook.Events {
+ events[i] = string(event)
+ }
+ webhookMap["events"] = events
+ }
+
+ if len(webhook.ProjectNamesFilter) > 0 {
+ webhookMap["project_names_filter"] = webhook.ProjectNamesFilter
+ }
+
+ if len(webhook.EnvironmentTypesFilter) > 0 {
+ envTypes := make([]string, len(webhook.EnvironmentTypesFilter))
+ for i, envType := range webhook.EnvironmentTypesFilter {
+ envTypes[i] = string(envType)
+ }
+ webhookMap["environment_types_filter"] = envTypes
+ }
+
+ results = append(results, webhookMap)
+ }
+
+ j, err := json.Marshal(results)
+ checkError(err)
+
+ return string(j)
+}
+
+func init() {
+ webhookCmd.AddCommand(webhookListCmd)
+ webhookListCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ webhookListCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+}
diff --git a/cmd/webhook_list_event.go b/cmd/webhook_list_event.go
new file mode 100644
index 00000000..26d36336
--- /dev/null
+++ b/cmd/webhook_list_event.go
@@ -0,0 +1,78 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+ "github.com/spf13/cobra"
+)
+
+var webhookListEventCmd = &cobra.Command{
+ Use: "list-event",
+ Short: "List webhook events",
+ Run: func(cmd *cobra.Command, args []string) {
+ utils.Capture(cmd)
+
+ client := utils.GetQoveryClientPanicInCaseOfError()
+ organizationId, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
+ checkError(err)
+
+ events, _, err := client.OrganizationWebhookAPI.ListWebhookEvent(context.Background(), organizationId, webhookId).Execute()
+ checkError(err)
+
+ if jsonFlag {
+ utils.Println(getWebhookEventJsonOutput(events))
+ return
+ }
+
+ var data [][]string
+ for _, event := range events.GetResults() {
+ data = append(data, []string{
+ event.Id,
+ event.CreatedAt.String(),
+ string(event.MatchedEvent),
+ string(event.Kind),
+ event.TargetUrlUsed,
+ fmt.Sprintf("%d", event.TargetResponseStatusCode),
+ *event.TargetResponseBody.Get(),
+ })
+ }
+
+ err = utils.PrintTable([]string{"ID", "Created At", "Event", "Kind", "Target URL", "Response Status Code", "Response Body"}, data)
+ checkError(err)
+ },
+}
+
+func getWebhookEventJsonOutput(events *qovery.WebhookEventResponseList) string {
+ var results []interface{}
+
+ for _, event := range events.GetResults() {
+ results = append(results, map[string]interface{}{
+ "id": event.Id,
+ "matched_event": string(event.MatchedEvent),
+ "kind": string(event.Kind),
+ "target_url_used": event.TargetUrlUsed,
+ "target_response_status_code": event.TargetResponseStatusCode,
+ "target_response_body": event.TargetResponseBody.Get(),
+ "created_at": event.CreatedAt.String(),
+ "payload": event.Request,
+ })
+ }
+
+ j, err := json.Marshal(results)
+ checkError(err)
+
+ return string(j)
+}
+
+func init() {
+ webhookCmd.AddCommand(webhookListEventCmd)
+ webhookListEventCmd.Flags().StringVarP(&organizationName, "organization", "", "", "Organization Name")
+ webhookListEventCmd.Flags().StringVarP(&webhookId, "webhook-id", "", "", "Webhook ID (UUID)")
+ webhookListEventCmd.Flags().BoolVarP(&jsonFlag, "json", "", false, "JSON output")
+ _ = webhookListEventCmd.MarkFlagRequired("webhook-id")
+}
diff --git a/default.nix b/default.nix
new file mode 100644
index 00000000..66b13a8d
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,11 @@
+# https://github.com/edolstra/flake-compat
+(import
+ (
+ let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
+ fetchTarball {
+ url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
+ sha256 = lock.nodes.flake-compat.locked.narHash;
+ }
+ )
+ { src = ./.; }
+).defaultNix
diff --git a/docker/exec.sh b/docker/exec.sh
new file mode 100644
index 00000000..eb0a3dfa
--- /dev/null
+++ b/docker/exec.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+eval "qovery $@"
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000..e462c4fe
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,60 @@
+{
+ "nodes": {
+ "flake-compat": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1627913399,
+ "narHash": "sha256-hY8g6H2KFL8ownSiFeMOjwPC8P0ueXpCVEbxgda3pko=",
+ "owner": "edolstra",
+ "repo": "flake-compat",
+ "rev": "12c64ca55c1014cdc1b16ed5a804aa8576601ff2",
+ "type": "github"
+ },
+ "original": {
+ "owner": "edolstra",
+ "repo": "flake-compat",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1653936696,
+ "narHash": "sha256-M6bJShji9AIDZ7Kh7CPwPBPb/T7RiVev2PAcOi4fxDQ=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "ce6aa13369b667ac2542593170993504932eb836",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "22.05",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-compat": "flake-compat",
+ "nixpkgs": "nixpkgs",
+ "utils": "utils"
+ }
+ },
+ "utils": {
+ "locked": {
+ "lastModified": 1623875721,
+ "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 00000000..4b8b9e90
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,48 @@
+{
+ description = "Qovery Command Line Interface";
+
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/22.05";
+ utils.url = "github:numtide/flake-utils";
+ flake-compat = {
+ url = "github:edolstra/flake-compat";
+ flake = false;
+ };
+ };
+
+ outputs = { self, nixpkgs, utils, ... }:
+ utils.lib.eachDefaultSystem
+ (system:
+ let
+ pkgs = import nixpkgs { inherit system; };
+ name = "qovery-cli";
+ version = "0.45.0"; # TODO: find a way to take the version from sources directly
+ vendorSha256 = "KHLknBymDAwr7OxS2Ysx6WU5KQ9kmw0bE2Hlp3CBW0c=";
+
+ in
+ rec {
+ # nix build
+ defaultPackage = pkgs.buildGoModule rec {
+ inherit version vendorSha256;
+ pname = name;
+ src = ./.;
+ };
+
+ # nix run
+ defaultApp = utils.lib.mkApp {
+ inherit name;
+ drv = defaultPackage;
+ };
+
+ # nix develop
+ devShell = pkgs.mkShell {
+ inputsFrom = builtins.attrValues self.defaultPackage;
+ nativeBuildInputs = with pkgs; [
+ # Nix LSP + formatter
+ rnix-lsp
+ nixpkgs-fmt
+ ];
+ };
+ }
+ );
+}
diff --git a/go.mod b/go.mod
index f3461cb6..93618279 100644
--- a/go.mod
+++ b/go.mod
@@ -1,92 +1,117 @@
module github.com/qovery/qovery-cli
-go 1.17
+go 1.25.0
+
+toolchain go1.25.1
require (
- github.com/AlecAivazis/survey/v2 v2.3.2
- github.com/containerd/console v1.0.3
- github.com/dgrijalva/jwt-go v3.2.0+incompatible
- github.com/fatih/color v1.13.0
- github.com/getsentry/sentry-go v0.12.0
- github.com/gorilla/websocket v1.4.2
- github.com/hashicorp/vault/api v1.3.1
- github.com/joho/godotenv v1.4.0
+ github.com/AlecAivazis/survey/v2 v2.3.7
+ github.com/Masterminds/semver/v3 v3.5.0
+ github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc
+ github.com/containerd/console v1.0.5
+ github.com/fatih/color v1.19.0
+ github.com/go-errors/errors v1.5.1
+ github.com/go-jose/go-jose/v4 v4.1.4
+ github.com/golang-jwt/jwt/v5 v5.3.1
+ github.com/google/uuid v1.6.0
+ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
+ github.com/jarcoal/httpmock v1.4.1
+ github.com/joho/godotenv v1.5.1
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
github.com/manifoldco/promptui v0.9.0
- github.com/mholt/archiver/v3 v3.5.1
- github.com/olekukonko/tablewriter v0.0.5
- github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
- github.com/posthog/posthog-go v0.0.0-20211028072449-93c17c49e2b0
- github.com/pterm/pterm v0.12.34
- github.com/qovery/qovery-client-go v0.0.0-20220127101633-6d8131211ac5
- github.com/sirupsen/logrus v1.8.1
- github.com/spf13/cobra v1.3.0
- github.com/spf13/pflag v1.0.5
- golang.org/x/net v0.0.0-20220127074510-2fabfed7e28f
- golang.org/x/sys v0.0.0-20220114195835-da31bd327af9
+ github.com/mholt/archives v0.1.5
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
+ github.com/pkg/errors v0.9.1
+ github.com/posthog/posthog-go v1.12.5
+ github.com/pterm/pterm v0.12.83
+ github.com/qovery/qovery-client-go v0.0.0-20260828080937-17e8dbfe5711
+ github.com/sirupsen/logrus v1.9.4
+ github.com/spf13/cobra v1.10.2
+ github.com/spf13/pflag v1.0.10
+ github.com/stretchr/testify v1.12.1
+ github.com/tonistiigi/go-rosetta v0.0.0-20220804170347-3f4430f2d346
+ github.com/xlab/treeprint v1.2.0
+ golang.org/x/sys v0.44.0
+ golang.org/x/term v0.43.0
+ gopkg.in/yaml.v3 v3.0.1
+ k8s.io/apimachinery v0.35.0
+ k8s.io/client-go v0.35.0
)
require (
- github.com/andybalholm/brotli v1.0.4 // indirect
- github.com/armon/go-metrics v0.3.10 // indirect
- github.com/armon/go-radix v1.0.0 // indirect
- github.com/atomicgo/cursor v0.0.1 // indirect
- github.com/cenkalti/backoff/v3 v3.0.0 // indirect
- github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
- github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
- github.com/golang/protobuf v1.5.2 // indirect
- github.com/golang/snappy v0.0.4 // indirect
- github.com/gookit/color v1.4.2 // indirect
- github.com/hashicorp/errwrap v1.1.0 // indirect
- github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
- github.com/hashicorp/go-hclog v1.0.0 // indirect
- github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
- github.com/hashicorp/go-multierror v1.1.1 // indirect
- github.com/hashicorp/go-plugin v1.4.3 // indirect
- github.com/hashicorp/go-retryablehttp v0.6.6 // indirect
- github.com/hashicorp/go-rootcerts v1.0.2 // indirect
- github.com/hashicorp/go-secure-stdlib/mlock v0.1.1 // indirect
- github.com/hashicorp/go-secure-stdlib/parseutil v0.1.1 // indirect
- github.com/hashicorp/go-secure-stdlib/strutil v0.1.1 // indirect
- github.com/hashicorp/go-sockaddr v1.0.2 // indirect
- github.com/hashicorp/go-uuid v1.0.2 // indirect
- github.com/hashicorp/go-version v1.2.0 // indirect
- github.com/hashicorp/golang-lru v0.5.4 // indirect
- github.com/hashicorp/hcl v1.0.0 // indirect
- github.com/hashicorp/vault/sdk v0.3.0 // indirect
- github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb // indirect
- github.com/inconshreveable/mousetrap v1.0.0 // indirect
+ atomicgo.dev/cursor v0.2.0 // indirect
+ atomicgo.dev/keyboard v0.2.10 // indirect
+ atomicgo.dev/schedule v0.1.0 // indirect
+ github.com/STARRY-S/zip v0.2.3 // indirect
+ github.com/andybalholm/brotli v1.2.1 // indirect
+ github.com/bodgit/plumbing v1.3.0 // indirect
+ github.com/bodgit/sevenzip v1.6.2 // indirect
+ github.com/bodgit/windows v1.0.1 // indirect
+ github.com/chzyer/readline v1.5.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
+ github.com/emicklei/go-restful/v3 v3.13.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.2 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-openapi/jsonpointer v0.23.1 // indirect
+ github.com/go-openapi/jsonreference v0.21.5 // indirect
+ github.com/go-openapi/swag v0.26.0 // indirect
+ github.com/go-openapi/swag/cmdutils v0.26.0 // indirect
+ github.com/go-openapi/swag/conv v0.26.0 // indirect
+ github.com/go-openapi/swag/fileutils v0.26.0 // indirect
+ github.com/go-openapi/swag/jsonname v0.26.0 // indirect
+ github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
+ github.com/go-openapi/swag/loading v0.26.0 // indirect
+ github.com/go-openapi/swag/mangling v0.26.0 // indirect
+ github.com/go-openapi/swag/netutils v0.26.0 // indirect
+ github.com/go-openapi/swag/stringutils v0.26.0 // indirect
+ github.com/go-openapi/swag/typeutils v0.26.0 // indirect
+ github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
+ github.com/goccy/go-json v0.10.6 // indirect
+ github.com/google/gnostic-models v0.7.1 // indirect
+ github.com/gookit/color v1.6.1 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
- github.com/klauspost/compress v1.11.4 // indirect
- github.com/klauspost/pgzip v1.2.5 // indirect
- github.com/mattn/go-colorable v0.1.12 // indirect
- github.com/mattn/go-isatty v0.0.14 // indirect
- github.com/mattn/go-runewidth v0.0.13 // indirect
- github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
- github.com/mitchellh/copystructure v1.0.0 // indirect
- github.com/mitchellh/go-homedir v1.1.0 // indirect
- github.com/mitchellh/go-testing-interface v1.0.0 // indirect
- github.com/mitchellh/mapstructure v1.4.3 // indirect
- github.com/mitchellh/reflectwalk v1.0.0 // indirect
- github.com/nwaples/rardecode v1.1.0 // indirect
- github.com/oklog/run v1.0.0 // indirect
- github.com/pierrec/lz4 v2.5.2+incompatible // indirect
- github.com/pierrec/lz4/v4 v4.1.2 // indirect
- github.com/rivo/uniseg v0.2.0 // indirect
- github.com/ryanuber/go-glob v1.0.0 // indirect
- github.com/ulikunitz/xz v0.5.9 // indirect
- github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
- github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
- github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
- go.uber.org/atomic v1.9.0 // indirect
- golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
- golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
- golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
- golang.org/x/text v0.3.7 // indirect
- golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
- google.golang.org/appengine v1.6.7 // indirect
- google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
- google.golang.org/grpc v1.42.0 // indirect
- google.golang.org/protobuf v1.27.1 // indirect
- gopkg.in/square/go-jose.v2 v2.5.1 // indirect
+ github.com/klauspost/compress v1.18.6 // indirect
+ github.com/klauspost/pgzip v1.2.6 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/lithammer/fuzzysearch v1.1.8 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.22 // indirect
+ github.com/mattn/go-runewidth v0.0.23 // indirect
+ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
+ github.com/mikelolasagasti/xz v1.0.1 // indirect
+ github.com/minio/minlz v1.1.1 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/nwaples/rardecode/v2 v2.2.2 // indirect
+ github.com/pierrec/lz4/v4 v4.1.26 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/sorairolake/lzip-go v0.3.8 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/ulikunitz/xz v0.5.15 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.5 // indirect
+ go4.org v0.0.0-20260112195520-a5071408f32f // indirect
+ golang.org/x/net v0.54.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ k8s.io/api v0.35.0 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260511211612-da4e56fe5676 // indirect
+ k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
)
diff --git a/go.sum b/go.sum
index 0fb2c70c..b8371aae 100644
--- a/go.sum
+++ b/go.sum
@@ -1,1114 +1,327 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
-cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
-cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
-cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
-cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
-cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
-cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
-cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
-cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
-cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
-cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
-cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
-cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
-cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
-cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
-cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
-cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
-cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
-cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
-cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
-cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
-cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
-cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
-cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
-cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
-cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
-cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
-cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
-cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
-cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
-cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
-cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
-cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
-cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
-cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
-cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
-cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
-cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
-cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO4gCnU8=
-github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg=
-github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
-github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
-github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
-github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
-github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
-github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
-github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
-github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k=
-github.com/MarvinJWendt/testza v0.2.12 h1:/PRp/BF+27t2ZxynTiqj0nyND5PbOtfJS0SuTuxmgeg=
-github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI=
-github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
-github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
-github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
-github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
-github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
-github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
-github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
-github.com/armon/go-metrics v0.3.10 h1:FR+drcQStOe+32sYyJYyZ7FIdgoGGBnwLl+flodp8Uo=
-github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
-github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
-github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU=
-github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
-github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c=
-github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-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.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
+atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg=
+atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ=
+atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw=
+atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU=
+atomicgo.dev/keyboard v0.2.10 h1:v7mvUKUZLHIggxULEIuWbT+WkkyQSgdbA201EziAhHU=
+atomicgo.dev/keyboard v0.2.10/go.mod h1:ap/z5ilnhLqYq852m6kPeTq5Z6aESGWu5mzRpJlC6aI=
+atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
+atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
+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/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4=
+github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY=
+github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+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/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
+github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
+github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
+github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc h1:LoL75er+LKDHDUfU5tRvFwxH0LjPpZN8OoG8Ll+liGU=
+github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc/go.mod h1:w648aMHEgFYS6xb0KVMMtZ2uMeemhiKCuD2vj6gY52A=
+github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
+github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
+github.com/bodgit/sevenzip v1.6.2 h1:6/0mwj5KaRXpuf9iSiE+VpG7VpzFJ8D60P53VjxRv34=
+github.com/bodgit/sevenzip v1.6.2/go.mod h1:q8DktB7GbvNn0Q6u4Iq6zULE0vo3rWtRHQg5L1XmjuU=
+github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
+github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
+github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
+github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
+github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
+github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
-github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
-github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
-github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
-github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
-github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
-github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
-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.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
+github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
+github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
+github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
-github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
-github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
-github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
-github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
+github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
-github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
-github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
-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.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
-github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
-github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
-github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
-github.com/evanphx/json-patch/v5 v5.5.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
-github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
-github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
-github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
-github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
-github.com/frankban/quicktest v1.10.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
-github.com/frankban/quicktest v1.13.0 h1:yNZif1OkDfNoDfb9zZa9aXIpejNR4F23Wely0c+Qdqk=
-github.com/frankban/quicktest v1.13.0/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/r/VLSOOIySU=
-github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
-github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
-github.com/getsentry/sentry-go v0.12.0 h1:era7g0re5iY13bHSdN/xMkyV+5zZppjRVQhZrXCaEIk=
-github.com/getsentry/sentry-go v0.12.0/go.mod h1:NSap0JBYWzHND8oMbyi0+XZhUalc1TBdRL1M71JZW2c=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
-github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
-github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
-github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
-github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
-github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
-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=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-ldap/ldap/v3 v3.1.10/go.mod h1:5Zun81jBTabRaI8lzN7E1JjyEl1g6zI6u9pd8luAK4Q=
-github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
-github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
-github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
-github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
-github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
-github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
-github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
-github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
-github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
-github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
+github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
+github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
+github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
+github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
+github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
+github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
+github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI=
+github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0=
+github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU=
+github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
+github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
+github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
+github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU=
+github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc=
+github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
+github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
+github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
+github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
+github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
+github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
+github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ=
+github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0=
+github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c=
+github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo=
+github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
+github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
+github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
+github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
+github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
+github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
+github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
+github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
+github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
+github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
+github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
+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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
-github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
-github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
-github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
-github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
-github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
-github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
-github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
-github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
-github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-hclog v1.0.0 h1:bkKf0BeBXcSYa7f5Fyi9gMuQ8gNsxeiNpZjR6VxNZeo=
-github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
-github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-kms-wrapping/entropy v0.1.0/go.mod h1:d1g9WGtAunDNpek8jUIEJnBlbgKS1N2Q61QkHiZyR1g=
-github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
-github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
-github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
-github.com/hashicorp/go-plugin v1.4.3 h1:DXmvivbWD5qdiBts9TpBC7BYL1Aia5sxbRgQB+v6UZM=
-github.com/hashicorp/go-plugin v1.4.3/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ=
-github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
-github.com/hashicorp/go-retryablehttp v0.6.6 h1:HJunrbHTDDbBb/ay4kxa1n+dLmttUlnP3V9oNE4hmsM=
-github.com/hashicorp/go-retryablehttp v0.6.6/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
-github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
-github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
-github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
-github.com/hashicorp/go-secure-stdlib/mlock v0.1.1 h1:cCRo8gK7oq6A2L6LICkUZ+/a5rLiRXFMf1Qd4xSwxTc=
-github.com/hashicorp/go-secure-stdlib/mlock v0.1.1/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I=
-github.com/hashicorp/go-secure-stdlib/parseutil v0.1.1 h1:78ki3QBevHwYrVxnyVeaEz+7WtifHhauYF23es/0KlI=
-github.com/hashicorp/go-secure-stdlib/parseutil v0.1.1/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8=
-github.com/hashicorp/go-secure-stdlib/password v0.1.1/go.mod h1:9hH302QllNwu1o2TGYtSk8I8kTAN0ca1EHpwhm5Mmzo=
-github.com/hashicorp/go-secure-stdlib/strutil v0.1.1 h1:nd0HIW15E6FG1MsnArYaHfuw9C2zgzM8LxkG5Ty/788=
-github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U=
-github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.1/go.mod h1:l8slYwnJA26yBz+ErHpp2IRCLr0vuOMGBORIz4rRiAs=
-github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
-github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
-github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
-github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
-github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
-github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E=
-github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
-github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
-github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
-github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
-github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
-github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
-github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
-github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
-github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
-github.com/hashicorp/vault/api v1.3.1 h1:pkDkcgTh47PRjY1NEFeofqR4W/HkNUi9qIakESO2aRM=
-github.com/hashicorp/vault/api v1.3.1/go.mod h1:QeJoWxMFt+MsuWcYhmwRLwKEXrjwAFFywzhptMsTIUw=
-github.com/hashicorp/vault/sdk v0.3.0 h1:kR3dpxNkhh/wr6ycaJYqp6AFT/i2xaftbfnwZduTKEY=
-github.com/hashicorp/vault/sdk v0.3.0/go.mod h1:aZ3fNuL5VNydQk8GcLJ2TV8YCRVvyaakYkhZRoVuhj0=
-github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb h1:b5rjCoWHc7eqmAS4/qyk21ZsHyb6Mxv/jykxvNTkU4M=
-github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
-github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
-github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
-github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
-github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
-github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
-github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk=
-github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g=
-github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
-github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
-github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE=
-github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74=
-github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
-github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
-github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
+github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E=
+github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU=
+github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
+github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
+github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
-github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8=
-github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE=
-github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE=
-github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro=
-github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
-github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
-github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
-github.com/klauspost/compress v1.11.4 h1:kz40R/YWls3iqT9zX9AHN3WoVsrAWVyui5sxuLqiXqU=
-github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/cpuid v1.2.0 h1:NMpwD2G9JSFOE1/TJjGSo5zG7Yb2bTe7eq1jH+irmeE=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
-github.com/klauspost/cpuid v1.2.1 h1:vJi+O/nMdFt0vqm8NZBI6wzALWdA2X+egi0ogNyrC/w=
-github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
-github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
-github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE=
-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/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ=
-github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
+github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y=
-github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
-github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
-github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
+github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
+github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
-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.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
-github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
-github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
-github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+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.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
-github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
-github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
-github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
-github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
-github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8=
-github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
+github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
+github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
-github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
-github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
-github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
-github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
-github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
-github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
-github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
-github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
-github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
-github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
-github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
-github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0=
-github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
-github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
-github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
-github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
-github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
+github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
+github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
+github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
+github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
+github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
+github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
+github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
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=
-github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
-github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
-github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
-github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
-github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7MQ=
-github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
-github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
-github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
-github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
-github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
-github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
-github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
-github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
-github.com/pierrec/lz4 v2.5.2+incompatible h1:WCjObylUIOlKy/+7Abdn34TLIkXiA4UWUMhxq9m9ZXI=
-github.com/pierrec/lz4 v2.5.2+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
-github.com/pierrec/lz4/v4 v4.1.2 h1:qvY3YFXRQE/XB8MlLzJH7mSzBs74eA2gg52YTk6jUPM=
-github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
-github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
-github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
-github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
-github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+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=
+github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
+github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
+github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
+github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
-github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
-github.com/posthog/posthog-go v0.0.0-20211028072449-93c17c49e2b0 h1:Y2hUrkfuM0on62KZOci/VLijlkdF/yeWU262BQgvcjE=
-github.com/posthog/posthog-go v0.0.0-20211028072449-93c17c49e2b0/go.mod h1:oa2sAs9tGai3VldabTV0eWejt/O4/OOD7azP8GaikqU=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
-github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
-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=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
-github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI=
-github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg=
-github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE=
-github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU=
-github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE=
-github.com/pterm/pterm v0.12.34 h1:6zfluSNr1P3u76TnjOr0ISe+AOZH+MZoFX57Zs1pm0k=
-github.com/pterm/pterm v0.12.34/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8=
-github.com/qovery/qovery-client-go v0.0.0-20220127101633-6d8131211ac5 h1:Am1BqtYLj0ihCliTt3+R3xckNN+zHvLM8DrBgcNOZqU=
-github.com/qovery/qovery-client-go v0.0.0-20220127101633-6d8131211ac5/go.mod h1:LVeny6ngXa26APqjwqwRk7okNxAvCQhHVWWpdVRDnh8=
-github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-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/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posthog/posthog-go v1.12.5 h1:l/x3mpqisXJ0sTOyyRutsTQAgiWYuJT1uhN4cQraJ8o=
+github.com/posthog/posthog-go v1.12.5/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
+github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
+github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk=
+github.com/qovery/qovery-client-go v0.0.0-20260828080937-17e8dbfe5711 h1:eH+5HvkAFzA2cpX7aAJWztpf7UYB5cmrNigVW818WTo=
+github.com/qovery/qovery-client-go v0.0.0-20260828080937-17e8dbfe5711/go.mod h1:qGyibtOSpR2wQeInNzFagLGRpQqlCGcRcFUiDf9FPLw=
+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.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
-github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
-github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
-github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
-github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
-github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
-github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
-github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
-github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/cobra v1.3.0 h1:R7cSvGu+Vv+qX0gW5R/85dx2kmmJT5z5NM8ifdYjdn0=
-github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4=
-github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
-github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
-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/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
-github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM=
+github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
+github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
+github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
-github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
-github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
-github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
-github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
-github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
+github.com/tonistiigi/go-rosetta v0.0.0-20220804170347-3f4430f2d346 h1:TvtdmeYsYEij78hS4oxnwikoiLdIrgav3BA+CbhaDAI=
+github.com/tonistiigi/go-rosetta v0.0.0-20220804170347-3f4430f2d346/go.mod h1:xKQhd7snlzKFuUi1taTGWjpRE8iFTA06DeacYi3CVFQ=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
-github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I=
-github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
-github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
-github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
-github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
-github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
-github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
-github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
-github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
-github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
-github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
-github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g=
-github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM=
-github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
-github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
-github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
-github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
-github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
-go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
-go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs=
-go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
-go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
-go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
-go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
-go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
-go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
-go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
+github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
+github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
+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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
+go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
-golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-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=
-golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
-golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
-golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-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/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=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
-golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
-golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
-golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
-golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220127074510-2fabfed7e28f h1:o66Bv9+w/vuk7Krcig9jZqD01FP7BL8OliFqqw0xzPI=
-golang.org/x/net v0.0.0-20220127074510-2fabfed7e28f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-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=
-golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
-golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-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/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-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=
-golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210603125802-9665404d3644/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-20210616045830-e2b7044e8c71/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-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0=
-golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
-golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
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=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-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-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
-golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
-golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
-golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
-google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
-google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
-google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
-google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
-google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
-google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
-google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
-google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
-google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
-google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
-google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
-google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
-google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
-google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
-google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
-google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
-google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
-google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
-google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
-google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
-google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
-google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
-google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
-google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
-google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
-google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
-google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0=
-google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
-google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
-google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
-google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
-google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
-google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A=
-google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
-google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
-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=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-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 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
-google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
-gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
-gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
-gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w=
-gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
+gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-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=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
-rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
-rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
+k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
+k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
+k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
+k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
+k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260511211612-da4e56fe5676 h1:ahjrVu/DBcaAhw/GcblfaOvvQ2wi8kqXWvn62nud3UU=
+k8s.io/kube-openapi v0.0.0-20260511211612-da4e56fe5676/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
+k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
+k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo=
+sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/openapi.yaml b/openapi.yaml
new file mode 100644
index 00000000..e69de29b
diff --git a/pkg/admin_cluster_deploy_by_batch.go b/pkg/admin_cluster_deploy_by_batch.go
new file mode 100644
index 00000000..63bf2f0f
--- /dev/null
+++ b/pkg/admin_cluster_deploy_by_batch.go
@@ -0,0 +1,54 @@
+package pkg
+
+import (
+ "fmt"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func DeployClustersByBatch(listService AdminClusterListService, deployService AdminClusterBatchDeployService, noConfirm bool) error {
+ clusters, err := listService.SelectClusters()
+ if err != nil {
+ return err
+ }
+
+ utils.Println(fmt.Sprintf("%d clusters to deploy:", len(clusters)))
+ err = PrintClustersTable(clusters)
+ if err != nil {
+ return err
+ }
+
+ deployService.PrintParameters()
+
+ if !noConfirm {
+ utils.Println("Do you want to continue deploy process ?")
+ var validated = utils.Validate("deploy")
+ if !validated {
+ utils.Println("Exiting: Validation failed")
+ return nil
+ }
+ }
+
+ deployResult, err := deployService.Deploy(clusters)
+ if err != nil {
+ return err
+ }
+
+ if len(deployResult.PendingClusters) > 0 {
+ utils.Println(fmt.Sprintf("%d clusters not triggered because in non-terminal state (queue not implemented yet):", len(deployResult.PendingClusters)))
+ err := PrintClustersTable(deployResult.PendingClusters)
+ if err != nil {
+ return err
+ }
+ }
+
+ if len(deployResult.ProcessedClusters) > 0 {
+ utils.Println(fmt.Sprintf("%d clusters deployed:", len(clusters)))
+ err := PrintClustersTable(deployResult.ProcessedClusters)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/admin_cluster_list.go b/pkg/admin_cluster_list.go
new file mode 100644
index 00000000..4a925b6e
--- /dev/null
+++ b/pkg/admin_cluster_list.go
@@ -0,0 +1,21 @@
+package pkg
+
+import (
+ "fmt"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func ListAllClusters(listService AdminClusterListService) error {
+ clusters, err := listService.SelectClusters()
+ if err != nil {
+ return err
+ }
+
+ utils.Println(fmt.Sprintf("Found %d clusters", len(clusters)))
+ err = PrintClustersTable(clusters)
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/pkg/admin_cluster_services.go b/pkg/admin_cluster_services.go
new file mode 100644
index 00000000..fc2d19fc
--- /dev/null
+++ b/pkg/admin_cluster_services.go
@@ -0,0 +1,666 @@
+package pkg
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "path"
+ "reflect"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+//
+// DTO
+
+type ListOfClustersEligibleToUpdate struct {
+ Results []ClusterDetails
+}
+type ClusterDetails struct {
+ OrganizationId string `json:"organization_id"`
+ OrganizationName string `json:"organization_name"`
+ OrganizationPlan string `json:"organization_plan"`
+ ClusterId string `json:"cluster_id"`
+ ClusterName string `json:"cluster_name"`
+ ClusterType string `json:"cluster_type"`
+ ClusterCreatedAt string `json:"cluster_created_at"`
+ ClusterLastDeployedAt string `json:"cluster_last_deployed_at"`
+ ClusterK8sVersion string `json:"cluster_k8s_version"`
+ Mode string `json:"mode"`
+ IsProduction bool `json:"is_production"`
+ CurrentStatus string `json:"current_status"`
+ HasKarpenter bool `json:"has_karpenter"`
+ HasPendingUpdate bool `json:"has_pending_update"`
+ HasMetricsFeature bool `json:"has_metrics_feature"`
+}
+
+// PrintClustersTable global method to output clusters table
+func PrintClustersTable(clusters []ClusterDetails) error {
+ var data [][]string
+
+ utils.Println("")
+ for _, cluster := range clusters {
+ data = append(data, []string{
+ cluster.OrganizationId,
+ cluster.OrganizationName,
+ cluster.OrganizationPlan,
+ cluster.ClusterId,
+ cluster.ClusterName,
+ cluster.ClusterType,
+ cluster.ClusterK8sVersion,
+ cluster.Mode,
+ strconv.FormatBool(cluster.IsProduction),
+ cluster.CurrentStatus,
+ strconv.FormatBool(cluster.HasKarpenter),
+ strconv.FormatBool(cluster.HasMetricsFeature),
+ cluster.ClusterCreatedAt,
+ cluster.ClusterLastDeployedAt,
+ strconv.FormatBool(cluster.HasPendingUpdate),
+ })
+ }
+
+ err := utils.PrintTable([]string{
+ "OrganizationId",
+ "OrganizationName",
+ "OrganizationPlan",
+ "ClusterId",
+ "ClusterName",
+ "ClusterType",
+ "ClusterK8sVersion",
+ "Mode",
+ "IsProduction",
+ "CurrentStatus",
+ "HasKarpenter",
+ "HasMetricsFeature",
+ "ClusterCreatedAt",
+ "ClusterLastDeployedAt",
+ "HasPendingUpdate",
+ }, data)
+ if err != nil {
+ return fmt.Errorf("cannot print clusters %s", err)
+ }
+ return nil
+}
+
+// Service to list clusters
+var allowedFilterProperties = map[string]bool{
+ "OrganizationId": true,
+ "OrganizationName": true,
+ "OrganizationPlan": true,
+ "ClusterId": true,
+ "ClusterName": true,
+ "ClusterType": true,
+ "ClusterK8sVersion": true,
+ "CurrentStatus": true,
+ "Mode": true,
+ "IsProduction": true,
+ "HasKarpenter": true,
+ "HasPendingUpdate": true,
+ "HasMetricsFeature": true,
+}
+
+type AdminClusterListService interface {
+ SelectClusters() ([]ClusterDetails, error)
+}
+
+type AdminClusterListServiceImpl struct {
+ // Filters based on ClusterDetails struct fields (reflection is used to filter fields)
+ Filters map[string]string
+}
+
+func NewAdminClusterListServiceImpl(filters map[string]string) (*AdminClusterListServiceImpl, error) {
+ if len(filters) > 0 {
+ for key := range filters {
+ _, keyIsPresent := allowedFilterProperties[key]
+ if !keyIsPresent {
+ keys := make([]string, len(allowedFilterProperties))
+ i := 0
+ for k := range allowedFilterProperties {
+ keys[i] = k
+ i++
+ }
+ return nil, fmt.Errorf("Filter property '%s' not available: valid values are: "+strings.Join(keys, ", "), key)
+ }
+ }
+ }
+
+ return &AdminClusterListServiceImpl{
+ Filters: filters,
+ }, nil
+}
+
+func (service AdminClusterListServiceImpl) SelectClusters() ([]ClusterDetails, error) {
+ clustersFetched, err := service.fetchClustersEligibleToUpdate()
+ if err != nil {
+ return nil, err
+ }
+ clusters := service.filterByPredicates(clustersFetched, service.Filters)
+ return clusters, nil
+}
+
+func UpdateClusterDomainName(clusterId string, domain string) error {
+ // Validate inputs
+ if clusterId == "" {
+ return fmt.Errorf("clusterId cannot be empty")
+ }
+ if domain == "" {
+ return fmt.Errorf("domain cannot be empty")
+ }
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return fmt.Errorf("failed to get access token: %w", err)
+ }
+
+ // Build URL with proper escaping
+ u, err := url.Parse(utils.GetAdminUrl())
+ if err != nil {
+ return fmt.Errorf("invalid admin URL: %w", err)
+ }
+ u.Path = path.Join(u.Path, "cluster", clusterId, "domain")
+ q := u.Query()
+ q.Set("name", domain)
+ u.RawQuery = q.Encode()
+
+ // Use PATCH or PUT for update operations
+ req, err := http.NewRequest(http.MethodPut, u.String(), nil)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Create client with timeout
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ }
+
+ res, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+ defer func() { _ = res.Body.Close() }()
+
+ // Check status code
+ if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent {
+ return fmt.Errorf("failed to update cluster domain (status=%d)",
+ res.StatusCode)
+ }
+
+ return nil
+}
+
+func UpdateClusterDnsProvider(
+ clusterId string,
+ domain string,
+ provider string,
+ cloudflareEmail string,
+ cloudflareToken string,
+ cloudflareProxied bool,
+ qoveryApiUrl string,
+ route53AccessKeyId string,
+ route53SecretAccessKey string,
+ route53Region string,
+ route53HostedZoneId string,
+) error {
+ // Validate inputs
+ if clusterId == "" {
+ return fmt.Errorf("clusterId cannot be empty")
+ }
+ if domain == "" {
+ return fmt.Errorf("domain cannot be empty")
+ }
+ if provider == "" {
+ return fmt.Errorf("provider cannot be empty")
+ }
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return fmt.Errorf("failed to get access token: %w", err)
+ }
+
+ // Build the request body based on the provider
+ type CloudflareCredentials struct {
+ Email string `json:"email"`
+ Token string `json:"token"`
+ IsProxied bool `json:"is_proxied"`
+ }
+
+ type QoveryCredentials struct {
+ ApiUrl string `json:"api_url"`
+ }
+
+ type Route53Credentials struct {
+ AwsAccessKeyId string `json:"aws_access_key_id"`
+ AwsSecretAccessKey string `json:"aws_secret_access_key"`
+ AwsRegion string `json:"aws_region"`
+ HostedZoneId *string `json:"hosted_zone_id,omitempty"`
+ }
+
+ type UpdateDnsProviderRequest struct {
+ Domain string `json:"domain"`
+ Cloudflare *CloudflareCredentials `json:"cloudflare,omitempty"`
+ Qovery *QoveryCredentials `json:"qovery,omitempty"`
+ Route53 *Route53Credentials `json:"route53,omitempty"`
+ }
+
+ requestBody := UpdateDnsProviderRequest{
+ Domain: domain,
+ }
+
+ switch provider {
+ case "cloudflare":
+ requestBody.Cloudflare = &CloudflareCredentials{
+ Email: cloudflareEmail,
+ Token: cloudflareToken,
+ IsProxied: cloudflareProxied,
+ }
+ case "qovery":
+ requestBody.Qovery = &QoveryCredentials{
+ ApiUrl: qoveryApiUrl,
+ }
+ case "route53":
+ creds := Route53Credentials{
+ AwsAccessKeyId: route53AccessKeyId,
+ AwsSecretAccessKey: route53SecretAccessKey,
+ AwsRegion: route53Region,
+ }
+ if route53HostedZoneId != "" {
+ creds.HostedZoneId = &route53HostedZoneId
+ }
+ requestBody.Route53 = &creds
+ default:
+ return fmt.Errorf("invalid provider: %s", provider)
+ }
+
+ // Marshal request body to JSON
+ bodyBytes, err := json.Marshal(requestBody)
+ if err != nil {
+ return fmt.Errorf("failed to marshal request body: %w", err)
+ }
+
+ // Build URL
+ u, err := url.Parse(utils.GetAdminUrl())
+ if err != nil {
+ return fmt.Errorf("invalid admin URL: %w", err)
+ }
+ u.Path = path.Join(u.Path, "cluster", clusterId, "updateDnsProvider")
+
+ // Create request
+ req, err := http.NewRequest(http.MethodPut, u.String(), bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Create client with timeout
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ }
+
+ res, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+ defer func() { _ = res.Body.Close() }()
+
+ // Check status code
+ if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent {
+ bodyBytes, _ := io.ReadAll(res.Body)
+ return fmt.Errorf("failed to update DNS provider (status=%d): %s",
+ res.StatusCode, string(bodyBytes))
+ }
+
+ return nil
+}
+
+func (service AdminClusterListServiceImpl) fetchClustersEligibleToUpdate() ([]ClusterDetails, error) {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest(http.MethodGet, utils.GetAdminUrl()+"/listClustersEligibleToUpdate", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ if res.StatusCode != 200 {
+ return nil, fmt.Errorf("cannot fetch clusters (status_code=%d)", res.StatusCode)
+ }
+
+ list := ListOfClustersEligibleToUpdate{}
+ err = json.NewDecoder(res.Body).Decode(&list)
+ if err != nil {
+ return nil, err
+ }
+
+ return list.Results, nil
+}
+
+func (service AdminClusterListServiceImpl) filterByPredicates(clusters []ClusterDetails, filters map[string]string) []ClusterDetails {
+ var filteredClusters []ClusterDetails
+ for _, cluster := range clusters {
+ matchAllFilters := true
+ for filterProperty, filterValue := range filters {
+ filterValuesSet := service.filterValueToHashSet(filterValue)
+ clusterProperty := reflect.Indirect(reflect.ValueOf(cluster)).FieldByName(filterProperty)
+
+ // hack for IsProduction field (boolean needs to be converted to string)
+ if filterProperty == "IsProduction" || filterProperty == "HasKarpenter" || filterProperty == "HasPendingUpdate" || filterProperty == "HasMetricsFeature" {
+ boolToString := strconv.FormatBool(clusterProperty.Bool())
+ if _, ok := filterValuesSet[boolToString]; !ok {
+ matchAllFilters = false
+ }
+ } else {
+ if _, ok := filterValuesSet[clusterProperty.String()]; !ok {
+ matchAllFilters = false
+ }
+ }
+
+ if !matchAllFilters {
+ break
+ }
+ }
+
+ if matchAllFilters {
+ filteredClusters = append(filteredClusters, cluster)
+ }
+ }
+ return filteredClusters
+}
+
+// filterValueToHashSet Actually it's a hashmap but golang has no hashset
+func (service AdminClusterListServiceImpl) filterValueToHashSet(filterValue string) map[string]bool {
+ splitFilterValue := strings.Split(filterValue, ",")
+ hashmap := make(map[string]bool, len(splitFilterValue))
+
+ for _, value := range splitFilterValue {
+ hashmap[value] = true
+ }
+
+ return hashmap
+}
+
+//
+// Service to deploy clusters
+
+type ClusterBatchDeployResult struct {
+ // ProcessedClusters clusters that have been processed, non matter the final state created
+ ProcessedClusters []ClusterDetails
+ // PendingClusters clusters in the pending queue (their state were not in ready state)
+ PendingClusters []ClusterDetails
+}
+
+type AdminClusterBatchDeployService interface {
+ Deploy(clusters []ClusterDetails) (*ClusterBatchDeployResult, error)
+ PrintParameters()
+}
+
+type AdminClusterBatchDeployServiceImpl struct {
+ client *qovery.ClustersAPIService
+ // DryRunDisabled disable dry run
+ DryRunDisabled bool
+ // ParallelRun the number of parallel requests to be processed
+ ParallelRun int
+ // RefreshDelay the delay to fetch cluster status in process
+ RefreshDelay int
+ // CompleteBatchBeforeContinue to block on N parallel runs to be processed: true = 'batch' mode / false = 'on-the-fly' mode
+ CompleteBatchBeforeContinue bool
+ // UpgradeClusterNewK8sVersion indicates next version to trigger a cluster upgrade
+ UpgradeClusterNewK8sVersion *string
+ // UpgradeMode indicates if the cluster needs to be upgraded
+ UpgradeMode bool
+ // NoConfirm do not prompt for any confirmation
+ NoConfirm bool
+}
+
+func NewAdminClusterBatchDeployServiceImpl(
+ client *qovery.ClustersAPIService,
+ dryRun bool,
+ parallelRun int,
+ refreshDelay int,
+ executionMode string,
+ newK8sversionStr string,
+ noConfirm bool,
+) (*AdminClusterBatchDeployServiceImpl, error) {
+ // set at least 1 parallel run
+ if parallelRun < 1 {
+ parallelRun = 1
+ }
+ if parallelRun > 20 && !noConfirm {
+ utils.Println("")
+ utils.Println(fmt.Sprintf("Please increase the cluster engine autoscaler to %d, then type 'yes' to continue", parallelRun))
+ validated := utils.Validate("autoscaler-increase")
+ if !validated {
+ utils.Println("Exiting")
+ return nil, fmt.Errorf("exit on autoscaler validation failed")
+ }
+ utils.Println("")
+ }
+
+ var newK8sVersion *string = nil
+ upgradeMode := false
+ if newK8sversionStr != "" {
+ newK8sVersion = &newK8sversionStr
+ upgradeMode = true
+ }
+
+ completeBatchBeforeContinue := executionMode != "on-the-fly" || upgradeMode
+
+ return &AdminClusterBatchDeployServiceImpl{
+ client: client,
+ DryRunDisabled: dryRun,
+ ParallelRun: parallelRun,
+ RefreshDelay: refreshDelay,
+ CompleteBatchBeforeContinue: completeBatchBeforeContinue,
+ UpgradeClusterNewK8sVersion: newK8sVersion,
+ UpgradeMode: upgradeMode,
+ }, nil
+}
+
+func (service AdminClusterBatchDeployServiceImpl) PrintParameters() {
+ utils.Println("-------------------------------------------")
+ utils.Println(fmt.Sprintf("- DryRunDisabled: %t", service.DryRunDisabled))
+ utils.Println(fmt.Sprintf("- ParallelRun: %d", service.ParallelRun))
+ utils.Println(fmt.Sprintf("- RefreshDelay: %d seconds", service.RefreshDelay))
+ utils.Println(fmt.Sprintf("- BatchMode: %t", service.CompleteBatchBeforeContinue))
+ if service.UpgradeMode {
+ utils.Println(fmt.Sprintf("- UpgradeMode: true (NewK8sVersion = %s)", *service.UpgradeClusterNewK8sVersion))
+ } else {
+ utils.Println("- UpgradeMode: false")
+ }
+ utils.Println("-------------------------------------------")
+}
+
+func getQoveryClient() (*qovery.APIClient, error) {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+ return utils.GetQoveryClient(tokenType, token), nil
+}
+
+func (service AdminClusterBatchDeployServiceImpl) Deploy(clusters []ClusterDetails) (*ClusterBatchDeployResult, error) {
+ if !service.DryRunDisabled {
+ utils.Println("dry-run-disabled is false: trigger cluster deployment dry-run mode (no changes will be made)")
+ }
+
+ // store final state of clusters in a hashmap
+ var processedClusters []ClusterDetails
+ // store the current status for each cluster deployed, to be able to execute next parallel runs
+ currentDeployingClustersByClusterId := make(map[string]ClusterDetails)
+ // clusters having a non-terminal state when trying to deploy them
+ var pendingClusters []ClusterDetails
+
+ indexCurrentClusterToDeploy := -1
+ for {
+ // fetch Qovery client
+ qoveryClient, err := getQoveryClient()
+ if err != nil {
+ return nil, err
+ }
+
+ // boolean to wait for current batch to continue, according to 'execution-mode' command flag
+ waitToTriggerCluster := false
+ if service.CompleteBatchBeforeContinue && indexCurrentClusterToDeploy != -1 {
+ if len(currentDeployingClustersByClusterId) > 0 {
+ waitToTriggerCluster = true
+ } else {
+ utils.Println(fmt.Sprintf("Do you want to continue next batch of %d deployments ?", service.ParallelRun))
+ validated := utils.Validate("deploy")
+ if !validated {
+ utils.Println("Exiting")
+ return nil, fmt.Errorf("user stopped the command after batch terminated")
+ }
+ }
+ }
+
+ // if enough space to start a new cluster deployment
+ if !waitToTriggerCluster && len(currentDeployingClustersByClusterId) < service.ParallelRun && indexCurrentClusterToDeploy < len(clusters)-1 {
+ // fill the hashmap according to parallel runs
+ for i := len(currentDeployingClustersByClusterId); i < service.ParallelRun; i++ {
+ indexCurrentClusterToDeploy += 1
+
+ // check status in case a deployment has occurred in the meantime
+ cluster := clusters[indexCurrentClusterToDeploy]
+
+ clusterStatus, response, err := RetryQoveryClientApiRequestOnUnauthorized(func(needToRefetchClient bool) (*qovery.ClusterStatus, *http.Response, error) {
+ if needToRefetchClient {
+ client, errQoveryClient := getQoveryClient()
+ if errQoveryClient != nil {
+ return nil, nil, errQoveryClient
+ }
+ qoveryClient = client
+ }
+ return qoveryClient.ClustersAPI.GetClusterStatus(context.Background(), cluster.OrganizationId, cluster.ClusterId).Execute()
+ })
+ if response == nil || response.StatusCode > 200 || err != nil {
+ return nil, err
+ }
+
+ // Trigger a deployment only when the target status is in terminal state
+ if utils.IsTerminalClusterState(clusterStatus.Status) {
+ utils.Println(fmt.Sprintf("[Organization '%s' - Cluster '%s'] - Starting deployment - https://console.qovery.com/organization/%s/cluster/%s/cluster-logs", cluster.OrganizationName, cluster.ClusterName, cluster.OrganizationId, cluster.ClusterId))
+ var err error
+ if service.UpgradeClusterNewK8sVersion != nil {
+ err = service.upgradeCluster(cluster.ClusterId, service.DryRunDisabled)
+ } else {
+ err = service.deployCluster(cluster.ClusterId, service.DryRunDisabled)
+ }
+ if err != nil {
+ utils.Println(fmt.Sprintf("[Organization '%s' - Cluster '%s'] - Error on deploy: %s ", cluster.OrganizationName, cluster.ClusterName, err))
+ }
+ cluster.CurrentStatus = "DEPLOYING"
+ currentDeployingClustersByClusterId[cluster.ClusterId] = cluster
+ } else {
+ status := fmt.Sprintf("%v", clusterStatus.Status) // only solution to get the underlying enum's string value
+ utils.Println(fmt.Sprintf("[Organization '%s' - Cluster '%s'] - Cluster's state is '%s' (not a terminal state), sending it to waiting queue to be processed later", cluster.OrganizationName, cluster.ClusterName, status))
+ pendingClusters = append(pendingClusters, cluster)
+ }
+
+ // if last cluster has been reached, break
+ if indexCurrentClusterToDeploy == len(clusters)-1 {
+ break
+ }
+ }
+ }
+
+ // sleep some time before fetching statuses
+ utils.Println(fmt.Sprintf("Checking clusters' status in %d seconds", service.RefreshDelay))
+ time.Sleep(time.Duration(service.RefreshDelay) * time.Second)
+
+ // wait for clusters statuses
+ var clustersToRemoveFromMap []string
+ for clusterId, cluster := range currentDeployingClustersByClusterId {
+ clusterStatus, response, err := RetryQoveryClientApiRequestOnUnauthorized(func(needToRefetchClient bool) (*qovery.ClusterStatus, *http.Response, error) {
+ if needToRefetchClient {
+ client, errQoveryClient := getQoveryClient()
+ if errQoveryClient != nil {
+ return nil, nil, errQoveryClient
+ }
+ qoveryClient = client
+ }
+ return qoveryClient.ClustersAPI.GetClusterStatus(context.Background(), cluster.OrganizationId, cluster.ClusterId).Execute()
+ })
+ if response == nil || response.StatusCode > 200 || err != nil {
+ return nil, err
+ }
+
+ // set cluster status
+ status := fmt.Sprintf("%v", clusterStatus.Status) // only solution to get the underlying enum's string value
+ cluster.CurrentStatus = status
+ // Mark the deployment as finished only if terminal state OR status is "INTERNAL_ERROR" (specific case)
+ if utils.IsTerminalClusterState(clusterStatus.Status) || cluster.CurrentStatus == "INTERNAL_ERROR" {
+ utils.Println(fmt.Sprintf("[Organization '%s' - Cluster '%s'] - Cluster deployed with '%s' status ", cluster.OrganizationName, cluster.ClusterName, clusterStatus.Status))
+
+ processedClusters = append(processedClusters, cluster)
+ clustersToRemoveFromMap = append(clustersToRemoveFromMap, clusterId)
+ }
+ }
+
+ // remove deployed clusters
+ for _, clusterId := range clustersToRemoveFromMap {
+ delete(currentDeployingClustersByClusterId, clusterId)
+ }
+
+ // check if every cluster has been deployed
+ if len(currentDeployingClustersByClusterId) == 0 && indexCurrentClusterToDeploy == len(clusters)-1 {
+ break
+ }
+ }
+
+ utils.Println("No more deployment to process")
+
+ return &ClusterBatchDeployResult{
+ ProcessedClusters: processedClusters,
+ PendingClusters: pendingClusters,
+ }, nil
+}
+
+func (service AdminClusterBatchDeployServiceImpl) deployCluster(clusterId string, dryRunDisabled bool) error {
+ adminUrl := utils.GetAdminUrl()
+ response := execAdminRequest(adminUrl+"/cluster/deploy/"+clusterId, http.MethodPost, dryRunDisabled, map[string]string{})
+ if response.StatusCode == 401 {
+ DoRequestUserToAuthenticate(false, true)
+ response = execAdminRequest(adminUrl+"/cluster/deploy/"+clusterId, http.MethodPost, dryRunDisabled, map[string]string{})
+ }
+ if response.StatusCode != 200 {
+ result, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("could not deploy cluster : %s. %s", response.Status, string(result))
+ }
+ return nil
+}
+
+func (service AdminClusterBatchDeployServiceImpl) upgradeCluster(clusterId string, dryRunDisabled bool) error {
+ if !dryRunDisabled {
+ utils.Println("dry-run-disabled is false: skip cluster upgrade")
+ return nil
+ }
+
+ _, _, err := service.client.UpgradeCluster(context.Background(), clusterId).Execute()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/pkg/admin_environment_deployment_rules.go b/pkg/admin_environment_deployment_rules.go
new file mode 100644
index 00000000..c55a7a89
--- /dev/null
+++ b/pkg/admin_environment_deployment_rules.go
@@ -0,0 +1,48 @@
+package pkg
+
+import (
+ "fmt"
+ log "github.com/sirupsen/logrus"
+ "net/http"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func PublishEnvironmentDeploymentRules() error {
+ utils.GetAdminUrl()
+
+ utils.Println("Publishing environment deployment rules to scheduler...")
+ err := callPublishEnvironmentDeploymentRulesApi()
+ if err != nil {
+ return err
+ }
+ utils.Println("Environment deployment rules successfully published to scheduler.")
+ return nil
+}
+
+func callPublishEnvironmentDeploymentRulesApi() error {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ url := fmt.Sprintf("%s/environmentDeploymentRules/pushToScheduler", utils.GetAdminUrl())
+ req, err := http.NewRequest(http.MethodPost, url, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if res.StatusCode != 200 {
+ log.Fatal(fmt.Sprintf("Failed to publish environment deployment rules to scheduler. Status code: %s", res.Status))
+ }
+ if err != nil {
+ log.Fatal(err)
+ }
+ return err
+}
diff --git a/pkg/admin_load_credentials.go b/pkg/admin_load_credentials.go
new file mode 100644
index 00000000..d75a6c9b
--- /dev/null
+++ b/pkg/admin_load_credentials.go
@@ -0,0 +1,231 @@
+package pkg
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/go-jose/go-jose/v4/json"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func LoadAwsCredentials(roleArn string) error {
+ awsStsCredentialsBody, err := fetchAwsCredentials(roleArn)
+ utils.CheckError(err)
+
+ awsStsCredentials := AwsStsCredentials{}
+ err = json.Unmarshal(awsStsCredentialsBody, &awsStsCredentials)
+ utils.CheckError(err)
+
+ // Set the environment variables for child processes
+ if err := os.Setenv("AWS_ACCESS_KEY_ID", awsStsCredentials.AccessKeyId); err != nil {
+ return fmt.Errorf("failed to set AWS_ACCESS_KEY_ID: %w", err)
+ }
+ if err := os.Setenv("AWS_SECRET_ACCESS_KEY", awsStsCredentials.SecretAccessKey); err != nil {
+ return fmt.Errorf("failed to set AWS_SECRET_ACCESS_KEY: %w", err)
+ }
+ if err := os.Setenv("AWS_SESSION_TOKEN", awsStsCredentials.SessionToken); err != nil {
+ return fmt.Errorf("failed to set AWS_SESSION_TOKEN: %w", err)
+ }
+ utils.PrintlnInfo("AWS credentials loaded successfully in current environment for child process. (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN)")
+
+ return StartChildShell()
+}
+
+func LoadCredentials(clusterId string, doNotConnectToBastion bool) error {
+ if !doNotConnectToBastion {
+ SetBastionConnection()
+ }
+ clusterCredentials := getClusterCredentials(clusterId)
+ if len(clusterCredentials) == 0 {
+ return fmt.Errorf("no credentials found for cluster ID %s", clusterId)
+ }
+ // Set the environment variables for child processes
+ for _, cred := range clusterCredentials {
+ if err := os.Setenv(cred.Key, cred.Value); err != nil {
+ return fmt.Errorf("failed to set environment variable %s: %w", cred.Key, err)
+ }
+ utils.PrintlnInfo(fmt.Sprintf("Set environment variable %s for child process", cred.Key))
+ }
+ kubeconfig := GetKubeconfigByClusterId(clusterId, false)
+ filePath := utils.WriteInFile(clusterId, "kubeconfig", []byte(kubeconfig))
+ if err := os.Setenv("KUBECONFIG", filePath); err != nil {
+ return fmt.Errorf("failed to set KUBECONFIG: %w", err)
+ }
+ if kubeconfigRequiresQoveryCommand(kubeconfig) {
+ if _, err := exec.LookPath("qovery"); err != nil {
+ utils.PrintlnInfo(fmt.Sprintf("KUBECONFIG uses qovery as an exec credential command, but qovery was not found in PATH: %v", err))
+ }
+ }
+ return StartChildShell()
+}
+
+func kubeconfigRequiresQoveryCommand(kubeconfig string) bool {
+ return strings.Contains(kubeconfig, "command: qovery")
+}
+
+func StartChildShell() error {
+ // Get the user's default shell
+ shell := os.Getenv("SHELL")
+ if shell == "" {
+ shell = "/bin/bash" // Default to bash if SHELL is not set
+ }
+ // Launch the shell
+ utils.PrintlnInfo("Launching new shell with credentials...")
+ cmd := exec.Command(shell)
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ err := cmd.Run()
+ if err != nil {
+ return fmt.Errorf("error launching shell: %v", err)
+ }
+ return nil
+}
+
+type AwsStsCredentials struct {
+ AccessKeyId string `json:"access_key_id"`
+ SecretAccessKey string `json:"secret_access_key"`
+ SessionToken string `json:"session_token"`
+}
+
+func fetchAwsCredentials(roleArn string) ([]byte, error) {
+ tokenType, token, err := utils.GetAccessToken(false)
+ utils.CheckError(err)
+
+ req, err := http.NewRequest(http.MethodPost, utils.GetAdminUrl()+"/aws/credentials/assume-role?role_arn="+roleArn, nil)
+ utils.CheckError(err)
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ utils.CheckError(err)
+ body, _ := io.ReadAll(res.Body)
+ if res.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("cannot fetch aws credentials (status_code=%d) %s", res.StatusCode, body)
+ }
+ utils.CheckError(err)
+ return body, nil
+}
+
+func getClusterCredentials(clusterId string) []utils.Var {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ url := fmt.Sprintf("%s/cluster/%s/credential", utils.GetAdminUrl(), clusterId)
+ req, err := http.NewRequest(http.MethodGet, url, bytes.NewBuffer([]byte("{}")))
+ if err != nil {
+ log.Fatal(err)
+ }
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ body, _ := io.ReadAll(res.Body)
+ if res.StatusCode != http.StatusOK {
+ err := fmt.Errorf("error retrieving cluster credentials: %s %s", res.Status, body)
+ utils.PrintlnError(err)
+ log.Fatal(err)
+ }
+
+ payload := map[string]string{}
+ err = json.Unmarshal(body, &payload)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ return clusterCredentialsFromPayload(clusterId, payload)
+}
+
+func clusterCredentialsFromPayload(clusterId string, payload map[string]string) []utils.Var {
+ var clusterCreds []utils.Var
+ isGcpPayload := isGcpCredentialsPayload(payload)
+ for key, value := range payload {
+ switch key {
+ case "access_key_id":
+ clusterCreds = append(clusterCreds, utils.Var{Key: "AWS_ACCESS_KEY_ID", Value: value})
+ case "secret_access_key":
+ clusterCreds = append(clusterCreds, utils.Var{Key: "AWS_SECRET_ACCESS_KEY", Value: value})
+ case "aws_session_token":
+ clusterCreds = append(clusterCreds, utils.Var{Key: "AWS_SESSION_TOKEN", Value: value})
+ case "region":
+ if isGcpPayload {
+ if _, hasGcpRegion := payload["gcp_region"]; !hasGcpRegion {
+ clusterCreds = appendGcpRegionVars(clusterCreds, value)
+ }
+ } else {
+ clusterCreds = append(clusterCreds, utils.Var{Key: "AWS_DEFAULT_REGION", Value: value})
+ }
+ case "scaleway_access_key":
+ clusterCreds = append(clusterCreds, utils.Var{Key: "SCW_ACCESS_KEY", Value: value})
+ case "scaleway_secret_key":
+ clusterCreds = append(clusterCreds, utils.Var{Key: "SCW_SECRET_KEY", Value: value})
+ case "scaleway_project_id":
+ clusterCreds = append(clusterCreds, utils.Var{Key: "SCW_PROJECT_ID", Value: value})
+ case "scaleway_organization_id":
+ clusterCreds = append(clusterCreds, utils.Var{Key: "SCW_ORGANIZATION_ID", Value: value})
+ case "json_credentials":
+ filepath := utils.WriteInFile(clusterId, "google_creds.json", []byte(value))
+
+ clusterCreds = append(clusterCreds, utils.Var{Key: "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE", Value: filepath})
+ clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_CREDENTIALS", Value: value})
+ case "gcp_access_token":
+ filepath := utils.WriteInFile(clusterId, "google_access_token", []byte(value))
+
+ clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_OAUTH_ACCESS_TOKEN", Value: value})
+ clusterCreds = append(clusterCreds, utils.Var{Key: "CLOUDSDK_AUTH_ACCESS_TOKEN_FILE", Value: filepath})
+ case "gcp_project_id":
+ clusterCreds = appendGcpProjectVars(clusterCreds, value)
+ case "gcp_region":
+ clusterCreds = appendGcpRegionVars(clusterCreds, value)
+ case "gcp_access_token_expiration":
+ clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_OAUTH_ACCESS_TOKEN_EXPIRATION", Value: value})
+ case "gcp_credentials_type":
+ clusterCreds = append(clusterCreds, utils.Var{Key: "GCP_CREDENTIALS_TYPE", Value: value})
+ }
+ }
+ return clusterCreds
+}
+
+func appendGcpProjectVars(clusterCreds []utils.Var, projectId string) []utils.Var {
+ clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_PROJECT", Value: projectId})
+ clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_CLOUD_PROJECT", Value: projectId})
+ clusterCreds = append(clusterCreds, utils.Var{Key: "CLOUDSDK_CORE_PROJECT", Value: projectId})
+ return clusterCreds
+}
+
+func appendGcpRegionVars(clusterCreds []utils.Var, region string) []utils.Var {
+ clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_REGION", Value: region})
+ clusterCreds = append(clusterCreds, utils.Var{Key: "CLOUDSDK_COMPUTE_REGION", Value: region})
+ return clusterCreds
+}
+
+func isGcpCredentialsPayload(payload map[string]string) bool {
+ gcpKeys := []string{
+ "json_credentials",
+ "gcp_access_token",
+ "gcp_project_id",
+ "gcp_region",
+ "gcp_access_token_expiration",
+ "gcp_credentials_type",
+ }
+ for _, key := range gcpKeys {
+ if _, ok := payload[key]; ok {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/admin_load_credentials_test.go b/pkg/admin_load_credentials_test.go
new file mode 100644
index 00000000..2aa2f886
--- /dev/null
+++ b/pkg/admin_load_credentials_test.go
@@ -0,0 +1,84 @@
+package pkg
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func TestClusterCredentialsFromPayload(t *testing.T) {
+ t.Run("Should map legacy GCP json credentials", func(t *testing.T) {
+ // given
+ clusterId := "test-legacy-gcp"
+ payload := map[string]string{
+ "json_credentials": "base64-json",
+ }
+
+ // when
+ credentials := clusterCredentialsFromPayload(clusterId, payload)
+
+ // then
+ assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_CREDENTIALS", Value: "base64-json"})
+ assert.Contains(t, credentials, utils.Var{Key: "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE", Value: "/tmp/qovery_test-legacy-gcp/google_creds.json"})
+ })
+
+ t.Run("Should map GCP workload identity federation access token credentials", func(t *testing.T) {
+ // given
+ payload := map[string]string{
+ "gcp_access_token": "access-token",
+ "gcp_project_id": "project-id",
+ "gcp_region": "europe-west1",
+ "gcp_access_token_expiration": "2026-06-08T17:00:00Z",
+ "gcp_credentials_type": "WORKLOAD_IDENTITY_FEDERATION",
+ }
+
+ // when
+ credentials := clusterCredentialsFromPayload("test-wif-gcp", payload)
+
+ // then
+ assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_OAUTH_ACCESS_TOKEN", Value: "access-token"})
+ assert.Contains(t, credentials, utils.Var{Key: "CLOUDSDK_AUTH_ACCESS_TOKEN_FILE", Value: "/tmp/qovery_test-wif-gcp/google_access_token"})
+ assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_PROJECT", Value: "project-id"})
+ assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_CLOUD_PROJECT", Value: "project-id"})
+ assert.Contains(t, credentials, utils.Var{Key: "CLOUDSDK_CORE_PROJECT", Value: "project-id"})
+ assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_REGION", Value: "europe-west1"})
+ assert.Contains(t, credentials, utils.Var{Key: "CLOUDSDK_COMPUTE_REGION", Value: "europe-west1"})
+ assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_OAUTH_ACCESS_TOKEN_EXPIRATION", Value: "2026-06-08T17:00:00Z"})
+ assert.Contains(t, credentials, utils.Var{Key: "GCP_CREDENTIALS_TYPE", Value: "WORKLOAD_IDENTITY_FEDERATION"})
+ })
+
+ t.Run("Should not map generic region to AWS_DEFAULT_REGION for GCP credentials", func(t *testing.T) {
+ // given
+ payload := map[string]string{
+ "gcp_access_token": "access-token",
+ "gcp_project_id": "project-id",
+ "gcp_credentials_type": "WORKLOAD_IDENTITY_FEDERATION",
+ "region": "europe-west9",
+ }
+
+ // when
+ credentials := clusterCredentialsFromPayload("test-wif-gcp-region", payload)
+
+ // then
+ assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_REGION", Value: "europe-west9"})
+ assert.Contains(t, credentials, utils.Var{Key: "CLOUDSDK_COMPUTE_REGION", Value: "europe-west9"})
+ assert.NotContains(t, credentials, utils.Var{Key: "AWS_DEFAULT_REGION", Value: "europe-west9"})
+ })
+
+ t.Run("Should keep mapping generic region to AWS_DEFAULT_REGION for AWS credentials", func(t *testing.T) {
+ // given
+ payload := map[string]string{
+ "access_key_id": "access-key",
+ "secret_access_key": "secret-key",
+ "region": "eu-west-3",
+ }
+
+ // when
+ credentials := clusterCredentialsFromPayload("test-aws", payload)
+
+ // then
+ assert.Contains(t, credentials, utils.Var{Key: "AWS_DEFAULT_REGION", Value: "eu-west-3"})
+ })
+}
diff --git a/pkg/admin_notify_users_cluster_failure.go b/pkg/admin_notify_users_cluster_failure.go
new file mode 100644
index 00000000..e0d695af
--- /dev/null
+++ b/pkg/admin_notify_users_cluster_failure.go
@@ -0,0 +1,56 @@
+package pkg
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func NotifyUsersClusterFailure(clusterId *string) error {
+ var body string
+ if clusterId != nil {
+ body = fmt.Sprintf(`{"cluster_ids": ["%s"]}`, *clusterId)
+ } else {
+ body = `{"all_failing_clusters": true}`
+ }
+
+ notifiedClustersResponse, err := postWithBody(utils.GetAdminUrl()+"/cluster/notifyFailedClustersAdmins", body)
+ if err != nil {
+ return err
+ }
+ result, _ := io.ReadAll(notifiedClustersResponse.Body)
+ if !strings.Contains(notifiedClustersResponse.Status, "200") {
+ return fmt.Errorf("could not notify (error %s: %s)", notifiedClustersResponse.Status, string(result))
+ }
+
+ utils.Println(fmt.Sprintf("Notification sent for admins of these clusters %s", string(result)))
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func postWithBody(url string, bodyAsString string) (*http.Response, error) {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ body := bytes.NewBuffer([]byte(bodyAsString))
+
+ req, err := http.NewRequest(http.MethodPost, url, body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ return http.DefaultClient.Do(req)
+}
diff --git a/pkg/auditlog/audit_log_service.go b/pkg/auditlog/audit_log_service.go
new file mode 100644
index 00000000..8767a8de
--- /dev/null
+++ b/pkg/auditlog/audit_log_service.go
@@ -0,0 +1,240 @@
+package auditlog
+
+import (
+ "encoding/csv"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+// Response structures for the audit logs API
+type AuditLogResponse struct {
+ Links Links `json:"links"`
+ Events []AuditEvent `json:"events"`
+}
+
+type Links struct {
+ Next string `json:"next"`
+}
+
+type AuditEvent struct {
+ ID string `json:"id"`
+ Timestamp string `json:"timestamp"`
+ EventType string `json:"event_type"`
+ TargetID string `json:"target_id"`
+ TargetName string `json:"target_name"`
+ TargetType string `json:"target_type"`
+ SubTargetType string `json:"sub_target_type"`
+ Origin string `json:"origin"`
+ TriggeredBy string `json:"triggered_by"`
+ ProjectID string `json:"project_id"`
+ ProjectName string `json:"project_name"`
+ EnvironmentID string `json:"environment_id"`
+ EnvironmentName string `json:"environment_name"`
+ EnvironmentType string `json:"environment_type"`
+ UserAgent string `json:"user_agent"`
+ Change string `json:"change"`
+}
+
+// DownloadOptions contains parameters for downloading audit logs
+type DownloadOptions struct {
+ OrganizationID string
+ FromDate string
+ ToDate string
+ TokenType string
+ Token string
+}
+
+// Service handles audit log operations
+type Service struct{}
+
+// NewService creates a new audit log service
+func NewService() *Service {
+ return &Service{}
+}
+
+// DownloadAuditLogs downloads audit logs and saves them to a CSV file
+func (s *Service) DownloadAuditLogs(options DownloadOptions) error {
+ // Parse from-date to timestamp
+ fromTimestamp, err := dateStringToTimestamp(options.FromDate)
+ utils.CheckError(err)
+
+ // Parse to-date to timestamp (if provided, otherwise use current time)
+ var toTimestamp int64
+ if options.ToDate != "" {
+ toTimestamp, err = dateStringToTimestamp(options.ToDate)
+ utils.CheckError(err)
+ } else {
+ toTimestamp = time.Now().Unix()
+ }
+
+ // Create output file
+ now := time.Now()
+ filename := fmt.Sprintf("audit_logs_%s.csv", now.Format("2006-01-02_15-04-05"))
+ file, err := os.Create(filename)
+ utils.CheckError(err)
+ defer func() {
+ if closeErr := file.Close(); closeErr != nil {
+ fmt.Printf("Warning: failed to close file: %v\n", closeErr)
+ }
+ }()
+
+ // Create CSV writer
+ csvWriter := csv.NewWriter(file)
+ defer csvWriter.Flush()
+
+ // Write CSV header
+ err = csvWriter.Write([]string{
+ "timestamp",
+ "event_type",
+ "target_id",
+ "target_name",
+ "target_type",
+ "sub_target_type",
+ "origin",
+ "triggered_by",
+ "project_id",
+ "project_name",
+ "environment_id",
+ "environment_name",
+ "environment_type",
+ "user_agent",
+ "change",
+ })
+ utils.CheckError(err)
+
+ fmt.Printf("Downloading audit logs to: %s\n", filename)
+
+ var continueToken string
+ totalEvents := 0
+ httpClient := &http.Client{}
+
+ for {
+ // Build API URL
+ apiURL := buildAPIURL(options.OrganizationID, fromTimestamp, toTimestamp, continueToken)
+
+ // Make HTTP request
+ response, err := makeHTTPRequest(httpClient, apiURL, options.TokenType, options.Token)
+ utils.CheckError(err)
+
+ // Process events
+ for _, event := range response.Events {
+ // Write to CSV (the change field contains JSON string that gets properly escaped)
+ err = csvWriter.Write([]string{
+ event.Timestamp,
+ event.EventType,
+ event.TargetID,
+ event.TargetName,
+ event.TargetType,
+ event.SubTargetType,
+ event.Origin,
+ event.TriggeredBy,
+ event.ProjectID,
+ event.ProjectName,
+ event.EnvironmentID,
+ event.EnvironmentName,
+ event.EnvironmentType,
+ event.UserAgent,
+ event.Change,
+ })
+ utils.CheckError(err)
+ }
+
+ totalEvents += len(response.Events)
+ fmt.Printf("\rđ Processing %d events...", totalEvents)
+
+ // Check if there are more pages
+ if response.Links.Next == "" {
+ break
+ }
+
+ // Parse continue token from next URL
+ continueToken, err = extractContinueToken(response.Links.Next)
+ if err != nil {
+ fmt.Printf("\nWarning: Could not parse continue token from URL: %s, error: %v\n", response.Links.Next, err)
+ break
+ }
+ }
+
+ fmt.Println("\nâ
Download complete!")
+ return nil
+}
+
+// dateStringToTimestamp converts a date string in ISO-8601 format to Unix timestamp
+func dateStringToTimestamp(dateStr string) (int64, error) {
+ t, err := time.Parse(time.RFC3339, dateStr)
+ utils.CheckError(err)
+ return t.Unix(), nil
+}
+
+// buildAPIURL constructs the API URL with query parameters
+func buildAPIURL(organizationId string, fromTimestamp, toTimestamp int64, continueToken string) string {
+ baseURL := fmt.Sprintf("https://api.qovery.com/organization/%s/events", organizationId)
+
+ params := url.Values{}
+ params.Add("fromTimestamp", strconv.FormatInt(fromTimestamp, 10))
+ params.Add("toTimestamp", strconv.FormatInt(toTimestamp, 10))
+ params.Add("pageSize", "100")
+
+ if continueToken != "" {
+ params.Add("continueToken", continueToken)
+ }
+
+ return baseURL + "?" + params.Encode()
+}
+
+// makeHTTPRequest performs the HTTP request and returns the parsed response
+func makeHTTPRequest(httpClient *http.Client, apiURL, tokenType, token string) (*AuditLogResponse, error) {
+ req, err := http.NewRequest("GET", apiURL, nil)
+ utils.CheckError(err)
+
+ // Set authorization header
+ req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Make the request
+ resp, err := httpClient.Do(req)
+ utils.CheckError(err)
+ defer func() {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ fmt.Printf("Warning: failed to close response body: %v\n", closeErr)
+ }
+ }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
+ }
+
+ // Parse response
+ body, err := io.ReadAll(resp.Body)
+ utils.CheckError(err)
+
+ var response AuditLogResponse
+ err = json.Unmarshal(body, &response)
+ utils.CheckError(err)
+
+ return &response, nil
+}
+
+// extractContinueToken parses the continue token from the next URL
+func extractContinueToken(nextURL string) (string, error) {
+ // Parse the URL
+ u, err := url.Parse(nextURL)
+ utils.CheckError(err)
+
+ // Extract continue token from query parameters
+ continueToken := u.Query().Get("continueToken")
+ if continueToken == "" {
+ return "", fmt.Errorf("continueToken not found in URL")
+ }
+
+ return continueToken, nil
+}
diff --git a/pkg/auth_service.go b/pkg/auth_service.go
new file mode 100644
index 00000000..1726494a
--- /dev/null
+++ b/pkg/auth_service.go
@@ -0,0 +1,279 @@
+package pkg
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math/rand"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/pkg/browser"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+const (
+ httpAuthPort = 10999
+ oAuthQoveryUrl = "https://auth.qovery.com/login?code_challenge_method=S256&scope=%s&client=%s&protocol=oauth2&response_type=%s&audience=%s&redirect_uri=%s&code_challenge=%s"
+)
+
+var (
+ oAuthUrlParamValueClient = "MJ2SJpu12PxIzgmc5z5Y7N8m5MnaF7Y0"
+ oAuthUrlParamValueHeadlessClient = "f9drkTNpxsEw2VU2PVDrxhyT3vVuFT0Y"
+ oAuthUrlParamValueAudience = "https://core.qovery.com"
+ oAuthUrlParamValueResponseType = "code"
+ oAuthUrlParamValueScopes = "offline_access openid profile email"
+ oAuthUrlParamValueRedirect = "http://localhost:" + strconv.Itoa(httpAuthPort) + "/authorization"
+ oAuthTokenEndpoint = "https://auth.qovery.com/oauth/token"
+)
+
+type TokensResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn uint `json:"expires_in"`
+}
+type DeviceFlowParameters struct {
+ DeviceCode string `json:"device_code"`
+ UserCode string `json:"user_code"`
+ VerificationUri string `json:"verification_uri"`
+ VerificationUriComplete string `json:"verification_uri_complete"`
+ ExpiresIn int64 `json:"expires_in"`
+ Interval int64 `json:"interval"`
+}
+
+func DoRequestUserToAuthenticate(headless bool, skipVersionCheck bool) {
+ qoveryConsoleUrl := "https://console.qovery.com"
+
+ if !skipVersionCheck {
+ available, message, _ := CheckAvailableNewVersion()
+ if available {
+ fmt.Println(message)
+ }
+ }
+ if headless {
+ runHeadlessFlow()
+ return
+ }
+
+ verifier := createCodeVerifier()
+ challenge, err := createCodeChallengeS256(verifier)
+ if err != nil {
+ utils.PrintlnError(errors.New("can not create authorization code challenge. Please contact the #support at 'https://discord.qovery.com'. "))
+ os.Exit(0)
+ }
+ // TODO link to web auth
+ _ = browser.OpenURL(fmt.Sprintf(oAuthQoveryUrl, url.QueryEscape(oAuthUrlParamValueScopes), oAuthUrlParamValueClient, url.QueryEscape(oAuthUrlParamValueResponseType),
+ url.QueryEscape(oAuthUrlParamValueAudience), url.QueryEscape(oAuthUrlParamValueRedirect), challenge))
+
+ fmt.Println("\nOpening your browser, waiting for your authentication... ")
+
+ srv := &http.Server{Addr: fmt.Sprintf("localhost:%d", httpAuthPort)}
+
+ http.HandleFunc("/authorization", func(writer http.ResponseWriter, request *http.Request) {
+ js := fmt.Sprintf(``, httpAuthPort)
+
+ _, _ = writer.Write([]byte(js))
+ _, _ = writer.Write([]byte("Authentication successful, you'll be redirected to Qovery console. If it's not the case, click on this link: " + qoveryConsoleUrl + ""))
+ })
+
+ http.HandleFunc("/authorization/valid", func(writer http.ResponseWriter, request *http.Request) {
+ code := request.URL.Query()["code"][0]
+ res, err := http.PostForm(oAuthTokenEndpoint, url.Values{
+ "grant_type": {"authorization_code"},
+ "client_id": {oAuthUrlParamValueClient},
+ "code": {code},
+ "redirect_uri": {oAuthUrlParamValueRedirect},
+ "code_verifier": {verifier},
+ })
+
+ if err != nil {
+ utils.PrintlnError(errors.New("authentication unsuccessful. Try again later or contact #support on 'https://discord.qovery.com'. "))
+ os.Exit(0)
+ } else {
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(res.Body)
+
+ tokens := TokensResponse{}
+ err := json.NewDecoder(res.Body).Decode(&tokens)
+ if err != nil {
+ utils.PrintlnError(errors.New("authentication unsuccessful. Try again later or contact #support on 'https://discord.qovery.com'. "))
+ os.Exit(0)
+ }
+ expiredAt := time.Now().Local().Add(time.Duration(tokens.ExpiresIn-60) * time.Second)
+ _ = utils.SetAccessToken(utils.AccessToken(tokens.AccessToken), expiredAt, utils.RefreshToken(tokens.RefreshToken))
+ utils.PrintlnInfo("Success!")
+ }
+
+ go func() {
+ time.Sleep(time.Second)
+ if err := srv.Shutdown(context.TODO()); err != nil {
+ utils.PrintlnError(err)
+ }
+ }()
+ })
+
+ _ = srv.ListenAndServe()
+}
+
+func createCodeVerifier() string {
+ length := 64
+ r := rand.New(rand.NewSource(time.Now().UnixNano()))
+ b := make([]byte, length)
+ for i := 0; i < length; i++ {
+ b[i] = byte(r.Intn(255))
+ }
+ return encode(b)
+}
+
+func createCodeChallengeS256(verifier string) (string, error) {
+ h := sha256.New()
+ _, err := h.Write([]byte(verifier))
+ if err != nil {
+ return "", err
+ }
+ return encode(h.Sum(nil)), nil
+}
+
+func encode(msg []byte) string {
+ encoded := base64.StdEncoding.EncodeToString(msg)
+ encoded = strings.ReplaceAll(encoded, "+", "-")
+ encoded = strings.ReplaceAll(encoded, "/", "_")
+ encoded = strings.ReplaceAll(encoded, "=", "")
+ return encoded
+}
+
+func runHeadlessFlow() {
+ parameters := deviceFlowParameters()
+ requestDeviceActivationWith(parameters)
+ start := time.Now()
+
+ fmt.Println("Waiting for code confirmation...")
+
+ for time.Since(start).Seconds() < float64(parameters.ExpiresIn) {
+ time.Sleep(time.Second * time.Duration(parameters.Interval))
+ tokens, err := getTokensWith(parameters)
+
+ if err == nil {
+ expiredAt := time.Now().Local().Add(time.Duration(tokens.ExpiresIn-60) * time.Second)
+ _ = utils.SetAccessToken(utils.AccessToken(tokens.AccessToken), expiredAt, utils.RefreshToken(tokens.RefreshToken))
+ utils.PrintlnInfo("Success!")
+ return
+ }
+ }
+
+ fmt.Println("Code has expired! ")
+ os.Exit(0)
+}
+
+func deviceFlowParameters() DeviceFlowParameters {
+ endpoint := "https://auth.qovery.com/oauth/device/code"
+ payload := strings.NewReader(fmt.Sprintf("client_id=%s&scope=%s&audience=%s&redirect_uri=%s", url.QueryEscape(oAuthUrlParamValueHeadlessClient), url.QueryEscape(oAuthUrlParamValueScopes), url.QueryEscape(oAuthUrlParamValueAudience), url.QueryEscape(oAuthUrlParamValueRedirect)))
+ req, err := http.NewRequest("POST", endpoint, payload)
+
+ if err != nil {
+ printContactSupportMessage("Error forming device code request. ")
+ os.Exit(0)
+ }
+
+ req.Header.Add("content-type", "application/x-www-form-urlencoded")
+ res, err := http.DefaultClient.Do(req)
+
+ if err != nil {
+ printContactSupportMessage("Error getting device code. ")
+ os.Exit(0)
+ }
+
+ if res.StatusCode == 200 {
+ defer func() {
+ if err := res.Body.Close(); err != nil {
+ utils.PrintlnError(fmt.Errorf("error closing response body: %w", err))
+ }
+ }()
+
+ parameters := DeviceFlowParameters{}
+ err = json.NewDecoder(res.Body).Decode(¶meters)
+
+ if err != nil {
+ printContactSupportMessage("Error parsing device code response. ")
+ os.Exit(0)
+ }
+
+ return parameters
+ } else {
+ printContactSupportMessage("Error getting device code. ")
+ os.Exit(0)
+ return DeviceFlowParameters{}
+ }
+}
+
+func printContactSupportMessage(msg string) {
+ fmt.Println(msg)
+ fmt.Println("Please contact the #support at 'https://discord.qovery.com'. ")
+}
+
+func requestDeviceActivationWith(params DeviceFlowParameters) {
+ fmt.Println("Please, open browser @ " + params.VerificationUri + " using any device and enter " + params.UserCode + " code. ")
+}
+
+func getTokensWith(params DeviceFlowParameters) (TokensResponse, error) {
+ endpoint := "https://auth.qovery.com/oauth/token"
+ payload := strings.NewReader("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=" + params.DeviceCode + "&client_id=" + oAuthUrlParamValueHeadlessClient)
+ req, err := http.NewRequest("POST", endpoint, payload)
+
+ if err != nil {
+ printContactSupportMessage("Error forming get access token request. ")
+ os.Exit(0)
+ }
+
+ req.Header.Add("content-type", "application/x-www-form-urlencoded")
+ res, err := http.DefaultClient.Do(req)
+
+ if err != nil {
+ printContactSupportMessage("Error pooling access token. ")
+ os.Exit(0)
+ }
+
+ defer func() {
+ if err := res.Body.Close(); err != nil {
+ utils.PrintlnError(fmt.Errorf("error closing response body: %w", err))
+ }
+ }()
+
+ if res.StatusCode == 200 {
+ tokens := TokensResponse{}
+ err = json.NewDecoder(res.Body).Decode(&tokens)
+ return tokens, err
+ } else {
+ return TokensResponse{}, errors.New("could not fetch tokens")
+ }
+}
+
+type QoveryClientApiRequest[T any] func(needToRefetchClient bool) (*T, *http.Response, error)
+
+// RetryQoveryClientApiRequestOnUnauthorized To be able to ask for re-auth when first attempt leads to unauthorized
+func RetryQoveryClientApiRequestOnUnauthorized[T any](request QoveryClientApiRequest[T]) (*T, *http.Response, error) {
+ qoveryStruct, response, err := request(false)
+ if response != nil && response.StatusCode == http.StatusUnauthorized {
+ utils.Println("Needs to re-authenticate as the response is UNAUTHORIZED (401)")
+ DoRequestUserToAuthenticate(false, true)
+ qoveryStruct, response, err = request(true)
+ }
+ return qoveryStruct, response, err
+}
diff --git a/pkg/bastion.go b/pkg/bastion.go
new file mode 100644
index 00000000..fadf76b2
--- /dev/null
+++ b/pkg/bastion.go
@@ -0,0 +1,114 @@
+package pkg
+
+import (
+ "context"
+ "fmt"
+ log "github.com/sirupsen/logrus"
+ "net"
+ "os"
+ "os/exec"
+ "syscall"
+ "time"
+)
+
+func SetBastionConnection() func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sshCmd, err := setupSSHConnection(ctx)
+ if err != nil {
+ log.Errorf("Failed to setup SSH connection: %v", err)
+ log.Warnf("Connection failure might be due to issues with your SSH configuration. Consider checking and updating your ~/.ssh/known_hosts file to ensure the host is trusted.")
+ return func() {}
+ }
+
+ return func() {
+ cleanupSSHConnection(sshCmd)
+ }
+}
+
+func setupSSHConnection(ctx context.Context) (*exec.Cmd, error) {
+ bastionAddress, ok := os.LookupEnv("BASTION_ADDR")
+ if !ok {
+ log.Error("You must set the bastion address (BASTION_ADDR).")
+ os.Exit(1)
+ }
+
+ sshArgs := []string{
+ "-N", "-D", "127.0.0.1:1080",
+ "-p", "2222",
+ "-4",
+ "-o", "StrictHostKeychecking=no",
+ "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "ServerAliveInterval=10",
+ "-o", "ServerAliveCountMax=3",
+ "-o", "TCPKeepAlive=yes",
+ fmt.Sprintf("root@%s", bastionAddress),
+ }
+
+ sshCmd := exec.Command("ssh", sshArgs...)
+ if err := sshCmd.Start(); err != nil {
+ return nil, fmt.Errorf("error starting SSH command: %v", err)
+ }
+
+ if err := waitForSSHConnection(ctx, "127.0.0.1:1080", 30*time.Second); err != nil {
+ if killErr := sshCmd.Process.Kill(); killErr != nil {
+ log.Errorf("failed to kill SSH process: %v", killErr)
+ }
+ return nil, fmt.Errorf("error waiting for SSH connection: %v", err)
+ }
+
+ log.Info("SSH connection established successfully")
+ if err := os.Setenv("HTTPS_PROXY", "socks5://127.0.0.1:1080"); err != nil {
+ if killErr := sshCmd.Process.Kill(); killErr != nil {
+ log.Errorf("failed to kill SSH process: %v", killErr)
+ }
+ return nil, fmt.Errorf("failed to set HTTPS_PROXY: %v", err)
+ }
+
+ return sshCmd, nil
+}
+
+func cleanupSSHConnection(sshCmd *exec.Cmd) {
+ if sshCmd != nil && sshCmd.Process != nil {
+ log.Info("Terminating SSH process...")
+ if err := sshCmd.Process.Signal(syscall.SIGTERM); err != nil {
+ log.Errorf("Failed to terminate SSH process: %v", err)
+ if err := sshCmd.Process.Kill(); err != nil {
+ log.Errorf("Failed to kill SSH process: %v", err)
+ }
+ }
+ _, _ = sshCmd.Process.Wait()
+ log.Info("SSH process terminated")
+ }
+
+ if err := os.Unsetenv("HTTPS_PROXY"); err != nil {
+ log.Errorf("Failed to unset HTTPS_PROXY: %v", err)
+ } else {
+ log.Info("HTTPS_PROXY has been unset")
+ }
+}
+
+func waitForSSHConnection(ctx context.Context, address string, timeout time.Duration) error {
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+
+ timeoutChan := time.After(timeout)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timeoutChan:
+ return fmt.Errorf("timeout waiting for SSH connection")
+ case <-ticker.C:
+ if conn, err := net.DialTimeout("tcp4", address, time.Second); err == nil {
+ err := conn.Close()
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+ }
+ }
+}
diff --git a/pkg/cluster.go b/pkg/cluster.go
new file mode 100644
index 00000000..d4e2930a
--- /dev/null
+++ b/pkg/cluster.go
@@ -0,0 +1,85 @@
+package pkg
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+)
+
+func GetKubeconfigByClusterId(clusterId string, readOnly bool) string {
+ qoveryClient := GetQoveryClientInstance()
+
+ request := qoveryClient.ClustersAPI.GetClusterKubeconfig(
+ context.Background(),
+ "00000000-0000-0000-000000000000",
+ clusterId,
+ ).WithTokenFromCli(true)
+
+ if readOnly {
+ request = request.ReadOnly(true)
+ }
+
+ response, httpResponse, err := qoveryClient.ClustersAPI.GetClusterKubeconfigExecute(request)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ if httpResponse.StatusCode != 200 {
+ utils.PrintlnInfo(fmt.Sprintf("cannot fetch cluster token (status_code=%d)", httpResponse.StatusCode))
+ os.Exit(1)
+ }
+ return response
+}
+
+func UpdateClusterKubeconfig(organizationId string, clusterId string, kubeconfig string) error {
+ qoveryClient := GetQoveryClientInstance()
+
+ request := qoveryClient.ClustersAPI.EditClusterKubeconfig(
+ context.Background(),
+ organizationId,
+ clusterId,
+ ).Body(kubeconfig)
+
+ // Execute the request
+ response, err := request.Execute()
+ if err != nil {
+ utils.PrintlnError(err)
+ return err
+ }
+ defer func() { _ = response.Body.Close() }()
+
+ return nil
+}
+
+func GetTokenByClusterId(clusterId string, readOnly bool) string {
+ qoveryClient := GetQoveryClientInstance()
+
+ request := qoveryClient.DefaultAPI.GetClusterTokenByClusterId(context.Background(), clusterId)
+ if readOnly {
+ request = request.ReadOnly(true)
+ }
+ _, response, err := qoveryClient.DefaultAPI.GetClusterTokenByClusterIdExecute(request)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ if response.StatusCode != 200 {
+ utils.PrintlnInfo(fmt.Sprintf("cannot fetch cluster token (status_code=%d)", response.StatusCode))
+ os.Exit(1)
+ }
+ body, _ := io.ReadAll(response.Body)
+ return string(body)
+}
+
+func GetQoveryClientInstance() *qovery.APIClient {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(1)
+ }
+ return utils.GetQoveryClient(tokenType, token)
+}
diff --git a/pkg/cluster/cluster_mock.go b/pkg/cluster/cluster_mock.go
new file mode 100644
index 00000000..59d8e28b
--- /dev/null
+++ b/pkg/cluster/cluster_mock.go
@@ -0,0 +1,194 @@
+//go:build testing
+
+package cluster
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/google/uuid"
+ "github.com/jarcoal/httpmock"
+ "github.com/qovery/qovery-client-go"
+ "net/http"
+ "time"
+)
+
+var allAdvancedSettingsByClusterId = make(map[string]qovery.ClusterAdvancedSettings)
+
+func CreateTestCluster(organization *qovery.Organization) *qovery.Cluster {
+ return qovery.NewCluster(uuid.NewString(), time.Now(), qovery.ReferenceObject{Id: organization.Id}, "TestCluster", "eu-west-3", qovery.CLOUDVENDORENUM_AWS)
+}
+
+func MockListClusters(organization *qovery.Organization, clusters []qovery.Cluster) {
+ var listClustersResponse = qovery.ClusterResponseList{Results: clusters}
+ var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster")
+ httpmock.RegisterResponder("GET", url,
+ func(req *http.Request) (*http.Response, error) {
+ resp, err := httpmock.NewJsonResponse(200, listClustersResponse)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+func MockDeployCluster(organization *qovery.Organization, cluster *qovery.Cluster, clusterState *qovery.ClusterStateEnum) {
+ var clusterStatus = qovery.ClusterStatus{
+ ClusterId: cluster.Id,
+ Status: clusterStateOrDefault(clusterState),
+ Reason: qovery.DEPLOYMENTINFRAREASON_UNSPECIFIED,
+ }
+ var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster/", cluster.Id, "/deploy")
+ httpmock.RegisterResponder("POST", url,
+ func(req *http.Request) (*http.Response, error) {
+ resp, err := httpmock.NewJsonResponse(200, clusterStatus)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+func MockStopCluster(organization *qovery.Organization, cluster *qovery.Cluster, clusterState *qovery.ClusterStateEnum) {
+ var clusterStatus = qovery.ClusterStatus{
+ ClusterId: cluster.Id,
+ Status: clusterStateOrDefault(clusterState),
+ Reason: qovery.DEPLOYMENTINFRAREASON_UNSPECIFIED,
+ }
+ var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster/", cluster.Id, "/stop")
+ httpmock.RegisterResponder("POST", url,
+ func(req *http.Request) (*http.Response, error) {
+ resp, err := httpmock.NewJsonResponse(200, clusterStatus)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+func MockGetClusterStatus(organization *qovery.Organization, cluster *qovery.Cluster, clusterState *qovery.ClusterStateEnum) {
+ var clusterStatus = qovery.ClusterStatus{
+ ClusterId: cluster.Id,
+ Status: clusterStateOrDefault(clusterState),
+ Reason: qovery.DEPLOYMENTINFRAREASON_UNSPECIFIED,
+ }
+ var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster/", cluster.Id, "/status")
+ httpmock.RegisterResponder("GET", url,
+ func(req *http.Request) (*http.Response, error) {
+ resp, err := httpmock.NewJsonResponse(200, clusterStatus)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+func clusterStateOrDefault(clusterState *qovery.ClusterStateEnum) qovery.ClusterStateEnum {
+ if clusterState == nil {
+ return qovery.CLUSTERSTATEENUM_DEPLOYED
+ }
+ return *clusterState
+}
+
+func MockCreateCluster(organization *qovery.Organization) {
+ var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster")
+ httpmock.RegisterResponder("POST", url,
+ func(req *http.Request) (*http.Response, error) {
+ // Decode & store the cluster request
+ var clusterRequest qovery.ClusterRequest
+ if err := json.NewDecoder(req.Body).Decode(&clusterRequest); err != nil {
+ return httpmock.NewStringResponse(400, ""), nil
+ }
+ var clusterResponse = qovery.NewClusterWithDefaults()
+ clusterResponse.Id = uuid.NewString()
+ clusterResponse.CreatedAt = time.Now()
+ clusterResponse.UpdatedAt = nil
+ clusterResponse.Organization = qovery.ReferenceObject{Id: organization.Id}
+ clusterResponse.Region = clusterRequest.Region
+ clusterResponse.CloudProvider = clusterRequest.CloudProvider
+ clusterResponse.Kubernetes = clusterRequest.Kubernetes
+ resp, err := httpmock.NewJsonResponse(200, clusterResponse)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ },
+ )
+}
+
+func MockGetClusterAdvancedSettings(organization *qovery.Organization, cluster *qovery.Cluster, advancedSettings *qovery.ClusterAdvancedSettings) {
+ var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster/", cluster.Id, "/advancedSettings")
+ httpmock.RegisterResponder("GET", url,
+ func(req *http.Request) (*http.Response, error) {
+ resp, err := httpmock.NewJsonResponse(200, advancedSettings)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+func MockEditClusterAdvancedSettings(organization *qovery.Organization, cluster *qovery.Cluster) {
+ var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster/", cluster.Id, "/advancedSettings")
+ httpmock.RegisterResponder("PUT", url,
+ func(req *http.Request) (*http.Response, error) {
+ var advancedSettings qovery.ClusterAdvancedSettings
+ if err := json.NewDecoder(req.Body).Decode(&advancedSettings); err != nil {
+ return httpmock.NewStringResponse(400, ""), nil
+ }
+ allAdvancedSettingsByClusterId[cluster.Id] = advancedSettings
+
+ resp, err := httpmock.NewJsonResponse(200, advancedSettings)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+func MockListCloudProviderRegion(cloudProviderType qovery.CloudProviderEnum, regions []qovery.ClusterRegion) {
+ var cloudProviderTypeApi string
+ switch cloudProviderType {
+ case qovery.CLOUDPROVIDERENUM_AWS:
+ cloudProviderTypeApi = "aws"
+ case qovery.CLOUDPROVIDERENUM_SCW:
+ cloudProviderTypeApi = "scaleway"
+ case qovery.CLOUDPROVIDERENUM_GCP:
+ cloudProviderTypeApi = "gcp"
+ case qovery.CLOUDPROVIDERENUM_ON_PREMISE:
+ cloudProviderTypeApi = "onPremise"
+ }
+ var url = fmt.Sprintf("https://api.qovery.com/%s/region", cloudProviderTypeApi)
+ httpmock.RegisterResponder("GET", url,
+ func(req *http.Request) (*http.Response, error) {
+ resp, err := httpmock.NewJsonResponse(200, qovery.ClusterRegionResponseList{Results: regions})
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+// TODO (mzo) set all ResultXXX to a func() error to be coherent with others
+type ClusterServiceMock struct {
+ ResultDeployCluster error
+ ResultStopCluster error
+ ResultListClusters func() (*qovery.ClusterResponseList, error)
+ ResultListClusterRegions func() (*qovery.ClusterRegionResponseList, error)
+ ResultAskToEditStorageClass error
+}
+
+func (mock *ClusterServiceMock) DeployCluster(organizationName string, clusterName string, watchFlag bool) error {
+ return mock.ResultDeployCluster
+}
+func (mock *ClusterServiceMock) StopCluster(organizationName string, clusterName string, watchFlag bool) error {
+ return mock.ResultStopCluster
+}
+func (mock *ClusterServiceMock) ListClusters(organizationId string) (*qovery.ClusterResponseList, error) {
+ return mock.ResultListClusters()
+}
+func (mock *ClusterServiceMock) ListClusterRegions(cloudProviderType qovery.CloudProviderEnum) (*qovery.ClusterRegionResponseList, error) {
+ return mock.ResultListClusterRegions()
+}
+func (mock *ClusterServiceMock) AskToEditStorageClass(cluster *qovery.Cluster) error {
+ return mock.ResultAskToEditStorageClass
+}
diff --git a/pkg/cluster/cluster_service.go b/pkg/cluster/cluster_service.go
new file mode 100644
index 00000000..35aa54d2
--- /dev/null
+++ b/pkg/cluster/cluster_service.go
@@ -0,0 +1,216 @@
+package cluster
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/go-errors/errors"
+ "github.com/pterm/pterm"
+
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+type ClusterService interface {
+ DeployCluster(organizationName string, clusterName string, watchFlag bool) error
+ StopCluster(organizationName string, clusterName string, watchFlag bool) error
+ ListClusters(organizationId string) (*qovery.ClusterResponseList, error)
+ ListClusterRegions(cloudProviderType qovery.CloudProviderEnum) (*qovery.ClusterRegionResponseList, error)
+ AskToEditStorageClass(cluster *qovery.Cluster) error
+}
+
+type ClusterServiceImpl struct {
+ client *qovery.APIClient
+ promptUiFactory promptuifactory.PromptUiFactory
+}
+
+func NewClusterService(
+ client *qovery.APIClient,
+ promptUiFactory promptuifactory.PromptUiFactory,
+) *ClusterServiceImpl {
+ return &ClusterServiceImpl{
+ client,
+ promptUiFactory,
+ }
+}
+
+func (service *ClusterServiceImpl) DeployCluster(organizationName string, clusterName string, watchFlag bool) error {
+ orgId, err := usercontext.GetOrganizationContextResourceId(service.client, organizationName)
+
+ if err != nil {
+ return err
+ }
+
+ clusters, _, err := service.client.ClustersAPI.ListOrganizationCluster(context.Background(), orgId).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ cluster := utils.FindByClusterName(clusters.GetResults(), clusterName)
+
+ if cluster == nil {
+ return errors.Errorf("cluster %s not found. You can list all clusters with: qovery cluster list", clusterName)
+ }
+
+ _, res, err := service.client.ClustersAPI.DeployCluster(context.Background(), orgId, cluster.Id).Execute()
+
+ if err != nil || res.StatusCode != 200 {
+ if res.StatusCode != 200 {
+ result, _ := io.ReadAll(res.Body)
+ return errors.Errorf("status code: %s ; body: %s ; error: %s", res.Status, string(result), err)
+ }
+ }
+
+ if watchFlag {
+ for {
+ status, _, err := service.client.ClustersAPI.GetClusterStatus(context.Background(), orgId, cluster.Id).Execute()
+ if err != nil {
+ return err
+ }
+
+ if utils.IsTerminalClusterState(status.Status) {
+ break
+ }
+
+ utils.Println(fmt.Sprintf("Cluster status: %s", utils.GetClusterStatusTextWithColor(status.GetStatus())))
+
+ // sleep here to avoid too many requests
+ time.Sleep(5 * time.Second)
+ }
+
+ utils.Println(fmt.Sprintf("Cluster %s deployed!", pterm.FgBlue.Sprintf("%s", clusterName)))
+ } else {
+ utils.Println(fmt.Sprintf("Deploying cluster %s in progress..", pterm.FgBlue.Sprintf("%s", clusterName)))
+ }
+
+ return nil
+}
+
+func (service *ClusterServiceImpl) StopCluster(organizationName string, clusterName string, watchFlag bool) error {
+ orgId, err := usercontext.GetOrganizationContextResourceId(service.client, organizationName)
+
+ if err != nil {
+ return err
+ }
+
+ clusters, _, err := service.client.ClustersAPI.ListOrganizationCluster(context.Background(), orgId).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ cluster := utils.FindByClusterName(clusters.GetResults(), clusterName)
+
+ if cluster == nil {
+ return fmt.Errorf("cluster %s not found. You can list all clusters with: qovery cluster list", clusterName)
+ }
+
+ _, _, err = service.client.ClustersAPI.StopCluster(context.Background(), orgId, cluster.Id).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ if watchFlag {
+ for {
+ status, _, err := service.client.ClustersAPI.GetClusterStatus(context.Background(), orgId, cluster.Id).Execute()
+ if err != nil {
+ return err
+ }
+
+ if utils.IsTerminalClusterState(status.Status) {
+ break
+ }
+
+ utils.Println(fmt.Sprintf("Cluster status: %s", utils.GetClusterStatusTextWithColor(status.GetStatus())))
+
+ // sleep here to avoid too many requests
+ time.Sleep(5 * time.Second)
+ }
+
+ utils.Println(fmt.Sprintf("Cluster %s stopped!", pterm.FgBlue.Sprintf("%s", clusterName)))
+ } else {
+ utils.Println(fmt.Sprintf("Stopping cluster %s in progress..", pterm.FgBlue.Sprintf("%s", clusterName)))
+ }
+
+ return nil
+}
+
+func (service *ClusterServiceImpl) GetClusterByID(organizationId string, clusterId string) (*qovery.Cluster, error) {
+ clusters, err := service.ListClusters(organizationId)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, cluster := range clusters.Results {
+ if cluster.Id == clusterId {
+ return &cluster, nil
+ }
+ }
+
+ return nil, errors.Errorf("Cluster with id %s doesn't exists in organization %s", clusterId, organizationId)
+}
+
+func (service *ClusterServiceImpl) ListClusters(organizationId string) (*qovery.ClusterResponseList, error) {
+ clusters, _, err := service.client.ClustersAPI.ListOrganizationCluster(context.Background(), organizationId).Execute()
+
+ if err != nil {
+ return nil, err
+ }
+
+ return clusters, nil
+}
+
+func (service *ClusterServiceImpl) ListClusterRegions(cloudProviderType qovery.CloudProviderEnum) (*qovery.ClusterRegionResponseList, error) {
+ switch cloudProviderType {
+ case qovery.CLOUDPROVIDERENUM_GCP:
+ regions, _, err := service.client.CloudProviderAPI.ListGcpRegions(context.Background()).Execute()
+ if err != nil {
+ return nil, err
+ }
+ return regions, nil
+ case qovery.CLOUDPROVIDERENUM_AWS:
+ regions, _, err := service.client.CloudProviderAPI.ListAWSRegions(context.Background()).Execute()
+ if err != nil {
+ return nil, err
+ }
+ return regions, nil
+ case qovery.CLOUDPROVIDERENUM_SCW:
+ regions, _, err := service.client.CloudProviderAPI.ListScalewayRegions(context.Background()).Execute()
+ if err != nil {
+ return nil, err
+ }
+ return regions, nil
+ default:
+ return nil, fmt.Errorf("cannot list regions for '%s' cloud provider", cloudProviderType)
+ }
+}
+
+func (service *ClusterServiceImpl) AskToEditStorageClass(cluster *qovery.Cluster) error {
+ storageClassName, err := service.promptUiFactory.RunPrompt("We need to know the storage class name that your kubernetes cluster uses to deploy app with network storage. Enter your storage class name", "")
+ if err != nil {
+ return err
+ }
+ if utils.IsEmptyOrBlank(storageClassName) {
+ return fmt.Errorf("storage class name should be defined and cannot be empty")
+ }
+
+ settings, _, err := service.client.ClustersAPI.GetClusterAdvancedSettings(context.Background(), cluster.Organization.Id, cluster.Id).Execute()
+ if err != nil {
+ return err
+ }
+
+ settings.StorageclassFastSsd = &storageClassName
+ _, _, err = service.client.ClustersAPI.EditClusterAdvancedSettings(context.Background(), cluster.Organization.Id, cluster.Id).ClusterAdvancedSettings(*settings).Execute()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/pkg/cluster/cluster_service_test.go b/pkg/cluster/cluster_service_test.go
new file mode 100644
index 00000000..5fdcaf06
--- /dev/null
+++ b/pkg/cluster/cluster_service_test.go
@@ -0,0 +1,360 @@
+package cluster
+
+import (
+ "fmt"
+ "github.com/jarcoal/httpmock"
+ "github.com/qovery/qovery-client-go"
+ "github.com/stretchr/testify/assert"
+ "testing"
+
+ mockOrganization "github.com/qovery/qovery-cli/pkg/organization"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func TestListClusters(t *testing.T) {
+ t.Run("Should return empty list if no cluster found", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ mockOrganization.MockListOrganizationsOk([]qovery.Organization{*organization})
+ MockListClusters(organization, []qovery.Cluster{})
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ var clusters, err = service.ListClusters(organization.Id)
+
+ // then
+ assert.Nil(t, err)
+ assert.Equal(t, 0, len(clusters.GetResults()))
+ })
+ t.Run("Should list clusters linked to organization selected", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks part
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = CreateTestCluster(organization)
+ mockOrganization.MockListOrganizationsOk([]qovery.Organization{*organization})
+ MockListClusters(organization, []qovery.Cluster{*cluster})
+ deployingState := qovery.CLUSTERSTATEENUM_DEPLOYING
+ MockDeployCluster(organization, cluster, &deployingState)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ var clusters, err = service.ListClusters(organization.Id)
+
+ // then
+ assert.Nil(t, err)
+ assert.Equal(t, 1, len(clusters.GetResults()))
+ assert.Equal(t, cluster.Id, clusters.GetResults()[0].Id)
+ })
+}
+
+func TestDeployManagedCluster(t *testing.T) {
+ t.Run("Should deploy cluster without waiting for final status", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = CreateTestCluster(organization)
+ mockOrganization.MockListOrganizationsOk([]qovery.Organization{*organization})
+ MockListClusters(organization, []qovery.Cluster{*cluster})
+ deployingState := qovery.CLUSTERSTATEENUM_DEPLOYING
+ MockDeployCluster(organization, cluster, &deployingState)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ err := service.DeployCluster("TestOrganization", "TestCluster", false)
+
+ // then
+ assert.Nil(t, err)
+ })
+
+ t.Run("Should deploy cluster with waiting for final status", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = CreateTestCluster(organization)
+ mockOrganization.MockListOrganizationsOk([]qovery.Organization{*organization})
+ MockListClusters(organization, []qovery.Cluster{*cluster})
+ deployingState := qovery.CLUSTERSTATEENUM_DEPLOYING
+ MockDeployCluster(organization, cluster, &deployingState)
+ deployedState := qovery.CLUSTERSTATEENUM_DEPLOYED
+ MockGetClusterStatus(organization, cluster, &deployedState)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ err := service.DeployCluster("TestOrganization", "TestCluster", true)
+
+ // then
+ assert.Nil(t, err)
+ })
+}
+
+func TestStopManagedCluster(t *testing.T) {
+ t.Run("Should stop cluster without waiting for final status", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = CreateTestCluster(organization)
+ mockOrganization.MockListOrganizationsOk([]qovery.Organization{*organization})
+ MockListClusters(organization, []qovery.Cluster{*cluster})
+ deployingState := qovery.CLUSTERSTATEENUM_DEPLOYING
+ MockStopCluster(organization, cluster, &deployingState)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ err := service.StopCluster("TestOrganization", "TestCluster", false)
+
+ // then
+ assert.Nil(t, err)
+ })
+ t.Run("Should stop cluster with waiting for final status", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = CreateTestCluster(organization)
+ mockOrganization.MockListOrganizationsOk([]qovery.Organization{*organization})
+ MockListClusters(organization, []qovery.Cluster{*cluster})
+ deployingState := qovery.CLUSTERSTATEENUM_DEPLOYING
+ MockStopCluster(organization, cluster, &deployingState)
+ deployedState := qovery.CLUSTERSTATEENUM_DEPLOYED
+ MockGetClusterStatus(organization, cluster, &deployedState)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ err := service.StopCluster("TestOrganization", "TestCluster", true)
+
+ // then
+ assert.Nil(t, err)
+ })
+}
+
+func TestAskToEditStorageClass(t *testing.T) {
+ t.Run("Should succeed to edit storage class", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = CreateTestCluster(organization)
+ var storageClass = "current storage class"
+ MockGetClusterAdvancedSettings(
+ organization,
+ cluster,
+ &qovery.ClusterAdvancedSettings{
+ StorageclassFastSsd: &storageClass,
+ },
+ )
+ MockEditClusterAdvancedSettings(organization, cluster)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{},
+ map[string]string{
+ "We need to know the storage class name that your kubernetes cluster uses to deploy app with network storage. Enter your storage class name": "new storage class",
+ },
+ ),
+ )
+
+ // when
+ err := service.AskToEditStorageClass(cluster)
+
+ // then
+ assert.Nil(t, err)
+ var clusterAdvancedSettings = allAdvancedSettingsByClusterId[cluster.Id]
+ assert.Equal(t, "new storage class", *clusterAdvancedSettings.StorageclassFastSsd)
+ })
+ t.Run("Should fail to edit storage class when storage class name prompt fails", func(t *testing.T) {
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = CreateTestCluster(organization)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{
+ "We need to know the storage class name that your kubernetes cluster uses to deploy app with network storage. Enter your storage class name": true,
+ },
+ map[string]string{},
+ ),
+ )
+
+ // when
+ err := service.AskToEditStorageClass(cluster)
+
+ // then
+ assert.NotNil(t, err)
+ assert.Equal(t, "error for prompt 'We need to know the storage class name that your kubernetes cluster uses to deploy app with network storage. Enter your storage class name'", err.Error())
+ })
+ t.Run("Should fail to edit storage class when storage class name is empty", func(t *testing.T) {
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = CreateTestCluster(organization)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{},
+ map[string]string{
+ "We need to know the storage class name that your kubernetes cluster uses to deploy app with network storage. Enter your storage class name": "",
+ },
+ ),
+ )
+
+ // when
+ err := service.AskToEditStorageClass(cluster)
+
+ // then
+ assert.NotNil(t, err)
+ assert.Equal(t, "storage class name should be defined and cannot be empty", err.Error())
+ })
+}
+
+func TestListClusterRegions(t *testing.T) {
+ testCases := []struct {
+ CloudProviderType qovery.CloudProviderEnum
+ ExpectedRegions []qovery.ClusterRegion
+ }{
+ {CloudProviderType: qovery.CLOUDPROVIDERENUM_AWS, ExpectedRegions: []qovery.ClusterRegion{{Name: "eu-west-3", CountryCode: "FR", Country: "France", City: "Paris"}}},
+ {CloudProviderType: qovery.CLOUDPROVIDERENUM_SCW, ExpectedRegions: []qovery.ClusterRegion{{Name: "eu-west-3", CountryCode: "FR", Country: "France", City: "Paris"}}},
+ {CloudProviderType: qovery.CLOUDPROVIDERENUM_GCP, ExpectedRegions: []qovery.ClusterRegion{{Name: "eu-west-3", CountryCode: "FR", Country: "France", City: "Paris"}}},
+ }
+ for _, testCase := range testCases {
+ t.Run(fmt.Sprintf("Should succeed to list regions for cluster type %s", testCase.CloudProviderType), func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ MockListCloudProviderRegion(testCase.CloudProviderType, testCase.ExpectedRegions)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{},
+ map[string]string{},
+ ),
+ )
+
+ // when
+ regions, err := service.ListClusterRegions(testCase.CloudProviderType)
+
+ // then
+ assert.Nil(t, err)
+ assert.Len(t, regions.Results, 1)
+ var region = regions.Results[0]
+ assert.Equal(t, testCase.ExpectedRegions[0].Name, region.Name)
+ assert.Equal(t, testCase.ExpectedRegions[0].Country, region.Country)
+ assert.Equal(t, testCase.ExpectedRegions[0].CountryCode, region.CountryCode)
+ assert.Equal(t, testCase.ExpectedRegions[0].City, region.City)
+ })
+ }
+ t.Run("Should trigger an error if cloud provider is On Premise as it is not handled", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ MockListCloudProviderRegion(qovery.CLOUDPROVIDERENUM_ON_PREMISE, nil)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{},
+ map[string]string{},
+ ),
+ )
+
+ // when
+ regions, err := service.ListClusterRegions(qovery.CLOUDPROVIDERENUM_ON_PREMISE)
+
+ // then
+ assert.Nil(t, regions)
+ assert.NotNil(t, err)
+ assert.Equal(t, "cannot list regions for 'ON_PREMISE' cloud provider", err.Error())
+ })
+}
+
+func TestGetHelmValues(t *testing.T) {
+ t.Run("Should succeed to edit storage class", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = CreateTestCluster(organization)
+ var storageClass = "current storage class"
+ MockGetClusterAdvancedSettings(
+ organization,
+ cluster,
+ &qovery.ClusterAdvancedSettings{
+ StorageclassFastSsd: &storageClass,
+ },
+ )
+ MockEditClusterAdvancedSettings(organization, cluster)
+
+ // given
+ service := NewClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{},
+ map[string]string{
+ "We need to know the storage class name that your kubernetes cluster uses to deploy app with network storage. Enter your storage class name": "new storage class",
+ },
+ ),
+ )
+
+ // when
+ err := service.AskToEditStorageClass(cluster)
+
+ // then
+ assert.Nil(t, err)
+ var clusterAdvancedSettings = allAdvancedSettingsByClusterId[cluster.Id]
+ assert.Equal(t, "new storage class", *clusterAdvancedSettings.StorageclassFastSsd)
+ })
+}
diff --git a/pkg/cluster/containerregistry/container_registry_mock.go b/pkg/cluster/containerregistry/container_registry_mock.go
new file mode 100644
index 00000000..801c62ab
--- /dev/null
+++ b/pkg/cluster/containerregistry/container_registry_mock.go
@@ -0,0 +1,63 @@
+//go:build testing
+
+package containerregistry
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/jarcoal/httpmock"
+ "github.com/qovery/qovery-client-go"
+ "net/http"
+)
+
+var allClusterContainerRegistryRequestsById = make(map[string]qovery.ContainerRegistryRequest)
+
+func MockListClusterContainerRegistries(organization *qovery.Organization, containerRegistries []qovery.ContainerRegistryResponse, forceFail bool) {
+ var response = qovery.ContainerRegistryResponseList{Results: containerRegistries}
+ var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/containerRegistry")
+ httpmock.RegisterResponder("GET", url,
+ func(req *http.Request) (*http.Response, error) {
+ if forceFail {
+ return httpmock.NewStringResponse(500, "Force failed enabled"), nil
+ }
+ resp, err := httpmock.NewJsonResponse(200, response)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+func MockEditClusterContainerRegistry(organization *qovery.Organization, containerRegistryId string, forceFail bool) {
+ var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/containerRegistry/", containerRegistryId)
+ httpmock.RegisterResponder("PUT", url,
+ func(req *http.Request) (*http.Response, error) {
+ if forceFail {
+ return httpmock.NewStringResponse(500, "Force failed enabled"), nil
+ }
+ var containerRegistryRequest qovery.ContainerRegistryRequest
+ if err := json.NewDecoder(req.Body).Decode(&containerRegistryRequest); err != nil {
+ return httpmock.NewStringResponse(400, ""), nil
+ }
+ allClusterContainerRegistryRequestsById[containerRegistryId] = containerRegistryRequest
+ resp, err := httpmock.NewJsonResponse(200, qovery.ContainerRegistryResponse{
+ Id: containerRegistryId,
+ Name: &containerRegistryRequest.Name,
+ Kind: &containerRegistryRequest.Kind,
+ Description: containerRegistryRequest.Description,
+ Url: containerRegistryRequest.Url,
+ })
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+type ContainerRegistryServiceMock struct {
+ ResultAskToEditClusterContainerRegistry error
+}
+
+func (mock *ContainerRegistryServiceMock) AskToEditClusterContainerRegistry(organizationId string, clusterId string) error {
+ return mock.ResultAskToEditClusterContainerRegistry
+}
diff --git a/pkg/cluster/containerregistry/container_registry_service.go b/pkg/cluster/containerregistry/container_registry_service.go
new file mode 100644
index 00000000..c88e940c
--- /dev/null
+++ b/pkg/cluster/containerregistry/container_registry_service.go
@@ -0,0 +1,136 @@
+package containerregistry
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "slices"
+
+ "github.com/fatih/color"
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+)
+
+type ClusterContainerRegistryService interface {
+ AskToEditClusterContainerRegistry(organizationId string, clusterId string) error
+}
+
+type ClusterContainerRegistryServiceImpl struct {
+ client *qovery.APIClient
+ promptUiFactory promptuifactory.PromptUiFactory
+}
+
+func NewClusterContainerRegistryService(
+ client *qovery.APIClient,
+ promptUiFactory promptuifactory.PromptUiFactory,
+) *ClusterContainerRegistryServiceImpl {
+ return &ClusterContainerRegistryServiceImpl{
+ client,
+ promptUiFactory,
+ }
+}
+
+func (service *ClusterContainerRegistryServiceImpl) AskToEditClusterContainerRegistry(organizationId string, clusterId string) error {
+ _, configureContainerRegistry, err := service.promptUiFactory.RunSelect(
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry",
+ []string{"Github", "A Generic One"},
+ )
+
+ if err != nil {
+ return err
+ }
+
+ resp, _, err := service.client.ContainerRegistriesAPI.ListContainerRegistry(context.Background(), organizationId).Execute()
+ if err != nil {
+ return err
+ }
+
+ // Only 1 container registry exists for a Self Managed cluster, so select it according to the Cluster Id
+ indexSelfManagedClusterRegistry := slices.IndexFunc(resp.GetResults(), func(c qovery.ContainerRegistryResponse) bool { return c.Cluster != nil && c.Cluster.Id == clusterId })
+ selfManagedClusterRegistry := resp.Results[indexSelfManagedClusterRegistry]
+
+ var registryInfo *AskRegistryInfo
+ switch configureContainerRegistry {
+ case "Github":
+ registryInfo, err = service.askGithubRegistryInfo()
+ if err != nil {
+ return err
+ }
+ case "A Generic One":
+ registryInfo, err = service.askGenericRegistryInfo(selfManagedClusterRegistry.Kind)
+ if err != nil {
+ return err
+ }
+ default:
+ return fmt.Errorf("cannot configure container registry: %s", configureContainerRegistry)
+ }
+
+ _, res, err := service.client.ContainerRegistriesAPI.EditContainerRegistry(context.Background(), organizationId, selfManagedClusterRegistry.Id).ContainerRegistryRequest(qovery.ContainerRegistryRequest{
+ Name: *selfManagedClusterRegistry.Name,
+ Kind: registryInfo.Kind,
+ Description: selfManagedClusterRegistry.Description,
+ Url: ®istryInfo.Url,
+ Config: qovery.ContainerRegistryRequestConfig{
+ Username: ®istryInfo.Login,
+ Password: ®istryInfo.Password,
+ },
+ }).Execute()
+
+ if err != nil {
+ body, _ := io.ReadAll(res.Body)
+ return fmt.Errorf("%s: %v", color.RedString("Error"), string(body))
+ }
+
+ return nil
+}
+
+type AskRegistryInfo struct {
+ Url string
+ Login string
+ Password string
+ Kind qovery.ContainerRegistryKindEnum
+}
+
+func (service *ClusterContainerRegistryServiceImpl) askGithubRegistryInfo() (*AskRegistryInfo, error) {
+ login, err := service.promptUiFactory.RunPrompt("Enter your Github username to login to the registry. It should be your Github username or Organisation name", "")
+ if err != nil {
+ return nil, err
+ }
+
+ password, err := service.promptUiFactory.RunPrompt("Enter your Github personal access token (classic) to login to the registry. It must have write and delete packages permissions", "")
+ if err != nil {
+ return nil, err
+ }
+
+ return &AskRegistryInfo{
+ Url: "https://ghcr.io",
+ Login: login,
+ Password: password,
+ Kind: qovery.CONTAINERREGISTRYKINDENUM_GITHUB_CR,
+ }, nil
+}
+
+func (service *ClusterContainerRegistryServiceImpl) askGenericRegistryInfo(clusterSelfManagedRegistryKind *qovery.ContainerRegistryKindEnum) (*AskRegistryInfo, error) {
+ url, err := service.promptUiFactory.RunPrompt("Url of your registry", "https://")
+ if err != nil {
+ return nil, err
+ }
+
+ login, err := service.promptUiFactory.RunPrompt("Username to use to login to your registry", "")
+ if err != nil {
+ return nil, err
+ }
+
+ password, err := service.promptUiFactory.RunPrompt("Password to use to login to your registry", "")
+ if err != nil {
+ return nil, err
+ }
+
+ return &AskRegistryInfo{
+ Url: url,
+ Login: login,
+ Password: password,
+ Kind: *clusterSelfManagedRegistryKind,
+ }, nil
+}
diff --git a/pkg/cluster/containerregistry/container_registry_test.go b/pkg/cluster/containerregistry/container_registry_test.go
new file mode 100644
index 00000000..706e0ad5
--- /dev/null
+++ b/pkg/cluster/containerregistry/container_registry_test.go
@@ -0,0 +1,424 @@
+package containerregistry
+
+import (
+ "github.com/jarcoal/httpmock"
+ "github.com/qovery/qovery-client-go"
+ "github.com/stretchr/testify/assert"
+ "testing"
+ "time"
+
+ mockCluster "github.com/qovery/qovery-cli/pkg/cluster"
+ mockOrganization "github.com/qovery/qovery-cli/pkg/organization"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func TestAskToEditGenericClusterContainerRegistry(t *testing.T) {
+ t.Run("Should edit successfully cluster container registry", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ var existingContainerRegistry = qovery.NewContainerRegistryResponse("id-container-registry-to-edit", time.Now())
+ existingContainerRegistry.SetName("container registry to edit")
+ existingContainerRegistry.SetUrl("https://ecr-url.com")
+ existingContainerRegistry.SetDescription("")
+ existingContainerRegistry.SetUpdatedAt(time.Now())
+ existingContainerRegistry.SetCluster(qovery.ContainerRegistryResponseAllOfCluster{Id: cluster.Id, Name: cluster.Name})
+ existingContainerRegistry.SetKind(qovery.CONTAINERREGISTRYKINDENUM_ECR)
+
+ MockListClusterContainerRegistries(
+ organization,
+ []qovery.ContainerRegistryResponse{*existingContainerRegistry},
+ false,
+ )
+ MockEditClusterContainerRegistry(organization, "id-container-registry-to-edit", false)
+
+ // given
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": "A Generic One",
+ "Url of your registry": "https://my-registry.com",
+ "Username to use to login to your registry": "foo",
+ "Password to use to login to your registry": "bar",
+ }),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.Nil(t, err)
+ })
+ t.Run("Should fail if configure prompt fails", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+
+ // given
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": true,
+ },
+ map[string]string{},
+ ),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.NotNil(t, err)
+ })
+ t.Run("Should fail if list container registries call fails", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ MockListClusterContainerRegistries(organization, []qovery.ContainerRegistryResponse{}, true)
+
+ // given
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{},
+ map[string]string{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": "A Generic One",
+ },
+ ),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.NotNil(t, err)
+ })
+ t.Run("Should fail if configure prompt results in unhandled container registry type", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ var existingContainerRegistry = qovery.NewContainerRegistryResponse("id-container-registry-to-edit", time.Now())
+ existingContainerRegistry.SetName("container registry to edit")
+ existingContainerRegistry.SetUrl("https://ecr-url.com")
+ existingContainerRegistry.SetDescription("")
+ existingContainerRegistry.SetUpdatedAt(time.Now())
+ existingContainerRegistry.SetCluster(qovery.ContainerRegistryResponseAllOfCluster{Id: cluster.Id, Name: cluster.Name})
+ existingContainerRegistry.SetKind(qovery.CONTAINERREGISTRYKINDENUM_ECR)
+ MockListClusterContainerRegistries(
+ organization,
+ []qovery.ContainerRegistryResponse{*existingContainerRegistry},
+ false,
+ )
+
+ // given
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{},
+ map[string]string{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": "Unhandled",
+ }),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.NotNil(t, err)
+ })
+ t.Run("Should fail if url prompt fails", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ var existingContainerRegistry = qovery.NewContainerRegistryResponse("id-container-registry-to-edit", time.Now())
+ existingContainerRegistry.SetName("container registry to edit")
+ existingContainerRegistry.SetUrl("https://ecr-url.com")
+ existingContainerRegistry.SetDescription("")
+ existingContainerRegistry.SetUpdatedAt(time.Now())
+ existingContainerRegistry.SetCluster(qovery.ContainerRegistryResponseAllOfCluster{Id: cluster.Id, Name: cluster.Name})
+ existingContainerRegistry.SetKind(qovery.CONTAINERREGISTRYKINDENUM_ECR)
+
+ MockListClusterContainerRegistries(
+ organization,
+ []qovery.ContainerRegistryResponse{*existingContainerRegistry},
+ false,
+ )
+ // given
+
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{
+ "Url of your registry": true,
+ },
+ map[string]string{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": "A Generic One",
+ }),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.NotNil(t, err)
+ })
+ t.Run("Should fail if username prompt fails", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ var existingContainerRegistry = qovery.NewContainerRegistryResponse("id-container-registry-to-edit", time.Now())
+ existingContainerRegistry.SetName("container registry to edit")
+ existingContainerRegistry.SetUrl("https://ecr-url.com")
+ existingContainerRegistry.SetDescription("")
+ existingContainerRegistry.SetUpdatedAt(time.Now())
+ existingContainerRegistry.SetCluster(qovery.ContainerRegistryResponseAllOfCluster{Id: cluster.Id, Name: cluster.Name})
+ existingContainerRegistry.SetKind(qovery.CONTAINERREGISTRYKINDENUM_ECR)
+
+ MockListClusterContainerRegistries(
+ organization,
+ []qovery.ContainerRegistryResponse{*existingContainerRegistry},
+ false,
+ )
+
+ // given
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{
+ "Username to use to login to your registry": true,
+ }, map[string]string{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": "A Generic One",
+ "Url of your registry": "https://my-registry.com",
+ }),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.NotNil(t, err)
+ })
+ t.Run("Should fail if password prompt fails", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ var existingContainerRegistry = qovery.NewContainerRegistryResponse("id-container-registry-to-edit", time.Now())
+ existingContainerRegistry.SetName("container registry to edit")
+ existingContainerRegistry.SetUrl("https://ecr-url.com")
+ existingContainerRegistry.SetDescription("")
+ existingContainerRegistry.SetUpdatedAt(time.Now())
+ existingContainerRegistry.SetCluster(qovery.ContainerRegistryResponseAllOfCluster{Id: cluster.Id, Name: cluster.Name})
+ existingContainerRegistry.SetKind(qovery.CONTAINERREGISTRYKINDENUM_ECR)
+
+ MockListClusterContainerRegistries(
+ organization,
+ []qovery.ContainerRegistryResponse{*existingContainerRegistry},
+ false,
+ )
+
+ // given
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{
+ "Password to use to login to your registry": true,
+ }, map[string]string{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": "A Generic One",
+ "Url of your registry": "https://my-registry.com",
+ "Username to use to login to your registry": "foo",
+ }),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.NotNil(t, err)
+ })
+ t.Run("Should fail if edit container registry call fails", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ var existingContainerRegistry = qovery.NewContainerRegistryResponse("id-container-registry-to-edit", time.Now())
+ existingContainerRegistry.SetName("container registry to edit")
+ existingContainerRegistry.SetUrl("https://ecr-url.com")
+ existingContainerRegistry.SetDescription("")
+ existingContainerRegistry.SetUpdatedAt(time.Now())
+ existingContainerRegistry.SetCluster(qovery.ContainerRegistryResponseAllOfCluster{Id: cluster.Id, Name: cluster.Name})
+ existingContainerRegistry.SetKind(qovery.CONTAINERREGISTRYKINDENUM_ECR)
+
+ MockListClusterContainerRegistries(
+ organization,
+ []qovery.ContainerRegistryResponse{*existingContainerRegistry},
+ false,
+ )
+ MockEditClusterContainerRegistry(organization, "id-container-registry-to-edit", true)
+
+ // given
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{},
+ map[string]string{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": "A Generic One",
+ "Url of your registry": "https://my-registry.com",
+ "Username to use to login to your registry": "foo",
+ "Password to use to login to your registry": "bar",
+ }),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.NotNil(t, err)
+ })
+}
+
+func TestAskToEditClusterGithubContainerRegistry(t *testing.T) {
+ t.Run("Should edit successfully github cluster container registry", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ var existingContainerRegistry = qovery.NewContainerRegistryResponse("id-container-registry-to-edit", time.Now())
+ existingContainerRegistry.SetName("container registry to edit")
+ existingContainerRegistry.SetUrl("https://ecr-url.com")
+ existingContainerRegistry.SetDescription("")
+ existingContainerRegistry.SetUpdatedAt(time.Now())
+ existingContainerRegistry.SetCluster(qovery.ContainerRegistryResponseAllOfCluster{Id: cluster.Id, Name: cluster.Name})
+ existingContainerRegistry.SetKind(qovery.CONTAINERREGISTRYKINDENUM_ECR)
+
+ MockListClusterContainerRegistries(
+ organization,
+ []qovery.ContainerRegistryResponse{*existingContainerRegistry},
+ false,
+ )
+ MockEditClusterContainerRegistry(organization, "id-container-registry-to-edit", false)
+
+ // given
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": "Github",
+ "Enter your Github username to login to the registry. It should be your Github username or Organisation name": "login_github",
+ "Enter your Github personal access token (classic) to login to the registry. It must have write and delete packages permissions": "token_github",
+ }),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.Nil(t, err)
+ })
+ t.Run("Should fail if login prompt fails", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ var existingContainerRegistry = qovery.NewContainerRegistryResponse("id-container-registry-to-edit", time.Now())
+ existingContainerRegistry.SetName("container registry to edit")
+ existingContainerRegistry.SetUrl("https://ecr-url.com")
+ existingContainerRegistry.SetDescription("")
+ existingContainerRegistry.SetUpdatedAt(time.Now())
+ existingContainerRegistry.SetCluster(qovery.ContainerRegistryResponseAllOfCluster{Id: cluster.Id, Name: cluster.Name})
+ existingContainerRegistry.SetKind(qovery.CONTAINERREGISTRYKINDENUM_ECR)
+
+ MockListClusterContainerRegistries(
+ organization,
+ []qovery.ContainerRegistryResponse{*existingContainerRegistry},
+ false,
+ )
+
+ // given
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{
+ "Enter your Github username to login to the registry. It should be your Github username or Organisation name": true,
+ },
+ map[string]string{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": "Github",
+ }),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.NotNil(t, err)
+ assert.Equal(t, "error for prompt 'Enter your Github username to login to the registry. It should be your Github username or Organisation name'", err.Error())
+ })
+ t.Run("Should fail if password prompt fails", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ var existingContainerRegistry = qovery.NewContainerRegistryResponse("id-container-registry-to-edit", time.Now())
+ existingContainerRegistry.SetName("container registry to edit")
+ existingContainerRegistry.SetUrl("https://ecr-url.com")
+ existingContainerRegistry.SetDescription("")
+ existingContainerRegistry.SetUpdatedAt(time.Now())
+ existingContainerRegistry.SetCluster(qovery.ContainerRegistryResponseAllOfCluster{Id: cluster.Id, Name: cluster.Name})
+ existingContainerRegistry.SetKind(qovery.CONTAINERREGISTRYKINDENUM_ECR)
+
+ MockListClusterContainerRegistries(
+ organization,
+ []qovery.ContainerRegistryResponse{*existingContainerRegistry},
+ false,
+ )
+
+ // given
+ var service = NewClusterContainerRegistryService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{
+ "Enter your Github personal access token (classic) to login to the registry. It must have write and delete packages permissions": true,
+ },
+ map[string]string{
+ "You need to configure a container registry that Qovery will use to push images for your cluster. Do you want to use as registry": "Github",
+ "Enter your Github username to login to the registry. It should be your Github username or Organisation name": "login_github",
+ }),
+ )
+
+ // when
+ var err = service.AskToEditClusterContainerRegistry(organization.Id, cluster.Id)
+
+ // then
+ assert.NotNil(t, err)
+ assert.Equal(t, "error for prompt 'Enter your Github personal access token (classic) to login to the registry. It must have write and delete packages permissions'", err.Error())
+ })
+}
diff --git a/pkg/cluster/credentials/cluster_credentials_mock.go b/pkg/cluster/credentials/cluster_credentials_mock.go
new file mode 100644
index 00000000..f631f740
--- /dev/null
+++ b/pkg/cluster/credentials/cluster_credentials_mock.go
@@ -0,0 +1,122 @@
+//go:build testing
+
+package credentials
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/jarcoal/httpmock"
+ "github.com/qovery/qovery-client-go"
+)
+
+// Stores credentials on POST for assert test purposes
+var allCredentialsById = make(map[string]interface{})
+
+// MockCreateAwsCredentials
+// Stores the request into a hashmap to keep track, and return the response with the generated uuid
+func MockCreateAwsCredentials(organization *qovery.Organization) {
+ mockCreateCloudProviderCredentials[qovery.AwsCredentialsRequest](organization, "aws")
+}
+
+// MockCreateScalewayCredentials
+// Stores the request into a hashmap to keep track, and return the response with the generated uuid
+func MockCreateScalewayCredentials(organization *qovery.Organization) {
+ mockCreateCloudProviderCredentials[qovery.ScalewayCredentialsRequest](organization, "scaleway")
+}
+
+// MockCreateGcpCredentials
+// Stores the request into a hashmap to keep track, and return the response with the generated uuid
+func MockCreateGcpCredentials(organization *qovery.Organization) {
+ mockCreateCloudProviderCredentials[qovery.GcpCredentialsRequest](organization, "gcp")
+}
+
+// MockOnPremiseCreateCredentials
+// Stores the request into a hashmap to keep track, and return the response with the generated uuid
+func MockOnPremiseCreateCredentials(organization *qovery.Organization) {
+ mockCreateCloudProviderCredentials[qovery.OnPremiseCredentialsRequest](organization, "onPremise")
+}
+
+func mockCreateCloudProviderCredentials[T any](organization *qovery.Organization, cloudProviderTypeUrl string) {
+ url := fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/", cloudProviderTypeUrl, "/credentials")
+ httpmock.RegisterResponder("POST", url,
+ func(req *http.Request) (*http.Response, error) {
+ // Read body bytes so we can decode twice (once into T, once into a map for name extraction)
+ bodyBytes, err := io.ReadAll(req.Body)
+ if err != nil {
+ return httpmock.NewStringResponse(400, ""), nil
+ }
+ // Decode & store the credentials request
+ var credentials T
+ if err := json.Unmarshal(bodyBytes, &credentials); err != nil {
+ return httpmock.NewStringResponse(400, ""), nil
+ }
+ generatedUuid := uuid.NewString()
+ allCredentialsById[generatedUuid] = credentials
+
+ // Extract name from the raw JSON (works for both flat and oneOf wrapper types)
+ var rawMap map[string]interface{}
+ if err := json.Unmarshal(bodyBytes, &rawMap); err != nil {
+ return httpmock.NewStringResponse(400, ""), nil
+ }
+ var credentialsName string
+ if name, ok := rawMap["name"].(string); ok {
+ credentialsName = name
+ }
+ var response qovery.ClusterCredentials
+ switch cloudProviderTypeUrl {
+ case "aws":
+ response = qovery.ClusterCredentials{AwsStaticClusterCredentials: &qovery.AwsStaticClusterCredentials{
+ Id: generatedUuid,
+ Name: credentialsName,
+ AccessKeyId: "",
+ ObjectType: "AWS",
+ }}
+ case "scaleway":
+ response = qovery.ClusterCredentials{ScalewayClusterCredentials: &qovery.ScalewayClusterCredentials{
+ Id: generatedUuid,
+ Name: credentialsName,
+ ObjectType: "SCW",
+ }}
+ default:
+ response = qovery.ClusterCredentials{GenericClusterCredentials: &qovery.GenericClusterCredentials{
+ Id: generatedUuid,
+ Name: credentialsName,
+ ObjectType: "OTHER",
+ }}
+ }
+ resp, err := httpmock.NewJsonResponse(200, response)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+func MockListCloudProviderCredentials(organization *qovery.Organization, results *qovery.ClusterCredentialsResponseList, cloudProviderTypeUrl string) {
+ url := fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/", cloudProviderTypeUrl, "/credentials")
+ httpmock.RegisterResponder("GET", url,
+ func(req *http.Request) (*http.Response, error) {
+ resp, err := httpmock.NewJsonResponse(200, results)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+type ClusterCredentialsServiceMock struct {
+ ResultListClusterCredentials func() (*qovery.ClusterCredentialsResponseList, error)
+ ResultAskToCreateCredentials func() (*qovery.ClusterCredentials, error)
+}
+
+func (mock *ClusterCredentialsServiceMock) ListClusterCredentials(organizationID string, cloudProviderType qovery.CloudProviderEnum) (*qovery.ClusterCredentialsResponseList, error) {
+ return mock.ResultListClusterCredentials()
+}
+
+func (mock *ClusterCredentialsServiceMock) AskToCreateCredentials(organizationID string, cloudProviderType qovery.CloudProviderEnum) (*qovery.ClusterCredentials, error) {
+ return mock.ResultAskToCreateCredentials()
+}
diff --git a/pkg/cluster/credentials/cluster_credentials_service.go b/pkg/cluster/credentials/cluster_credentials_service.go
new file mode 100644
index 00000000..630d5642
--- /dev/null
+++ b/pkg/cluster/credentials/cluster_credentials_service.go
@@ -0,0 +1,251 @@
+package credentials
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/fatih/color"
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+const (
+ gcpCredentialsTypeWif = "Workload Identity Federation"
+ gcpCredentialsTypeServiceAccount = "Service Account JSON Key"
+)
+
+type ClusterCredentialsService interface {
+ ListClusterCredentials(organizationID string, cloudProviderType qovery.CloudProviderEnum) (*qovery.ClusterCredentialsResponseList, error)
+ AskToCreateCredentials(organizationID string, cloudProviderType qovery.CloudProviderEnum) (*qovery.ClusterCredentials, error)
+}
+
+type ClusterCredentialsServiceImpl struct {
+ client *qovery.APIClient
+ promptUiFactory promptuifactory.PromptUiFactory
+}
+
+func NewClusterCredentialsService(
+ client *qovery.APIClient,
+ promptUiFactory promptuifactory.PromptUiFactory,
+) *ClusterCredentialsServiceImpl {
+ return &ClusterCredentialsServiceImpl{
+ client,
+ promptUiFactory,
+ }
+}
+
+func (service *ClusterCredentialsServiceImpl) ListClusterCredentials(organizationID string, cloudProviderType qovery.CloudProviderEnum) (*qovery.ClusterCredentialsResponseList, error) {
+ switch cloudProviderType {
+ case qovery.CLOUDPROVIDERENUM_GCP:
+ req := service.client.CloudProviderCredentialsAPI.ListGcpCredentials(context.Background(), organizationID)
+ creds, _, err := service.client.CloudProviderCredentialsAPI.ListGcpCredentialsExecute(req)
+ if err != nil {
+ return nil, err
+ }
+ return creds, nil
+ case qovery.CLOUDPROVIDERENUM_AWS:
+ req := service.client.CloudProviderCredentialsAPI.ListAWSCredentials(context.Background(), organizationID)
+ creds, _, err := service.client.CloudProviderCredentialsAPI.ListAWSCredentialsExecute(req)
+ if err != nil {
+ return nil, err
+ }
+ return creds, nil
+ case qovery.CLOUDPROVIDERENUM_SCW:
+ req := service.client.CloudProviderCredentialsAPI.ListScalewayCredentials(context.Background(), organizationID)
+ creds, _, err := service.client.CloudProviderCredentialsAPI.ListScalewayCredentialsExecute(req)
+ if err != nil {
+ return nil, err
+ }
+ return creds, nil
+ case qovery.CLOUDPROVIDERENUM_ON_PREMISE:
+ req := service.client.CloudProviderCredentialsAPI.ListOnPremiseCredentials(context.Background(), organizationID)
+ creds, _, err := service.client.CloudProviderCredentialsAPI.ListOnPremiseCredentialsExecute(req)
+ if err != nil {
+ return nil, err
+ }
+ return creds, nil
+ default:
+ return nil, fmt.Errorf("cannot list credentias for '%s' cloud provider type", cloudProviderType)
+ }
+}
+
+func (service *ClusterCredentialsServiceImpl) AskToCreateCredentials(
+ organizationID string,
+ cloudProviderType qovery.CloudProviderEnum,
+) (*qovery.ClusterCredentials, error) {
+ // Early return for ON_PREMISE cloud provider
+ // As the name of the credentials is forced to the value "on-premise", no need to require user to enter some credentials name
+ if cloudProviderType == qovery.CLOUDPROVIDERENUM_ON_PREMISE {
+ creds, resp, err := service.client.CloudProviderCredentialsAPI.CreateOnPremiseCredentials(context.Background(), organizationID).OnPremiseCredentialsRequest(qovery.OnPremiseCredentialsRequest{
+ Name: "on-premise",
+ }).Execute()
+ if apiErr := formatCloudProviderCredentialsApiError(resp, err); apiErr != nil {
+ return nil, apiErr
+ }
+ return creds, nil
+ }
+
+ // Normal path
+ credentialsName, err := service.promptUiFactory.RunPrompt("Give a name to your credentials", "")
+ if err != nil {
+ return nil, err
+ }
+
+ // Check if credentials name is not empty or blank
+ if utils.IsEmptyOrBlank(credentialsName) {
+ return nil, fmt.Errorf("please enter a non-empty name for your credentials")
+ }
+
+ switch cloudProviderType {
+ case qovery.CLOUDPROVIDERENUM_AWS:
+ accessKey, err := service.promptUiFactory.RunPrompt("Enter your AWS access key", "")
+ if err != nil {
+ return nil, err
+ }
+ secretKey, err := service.promptUiFactory.RunPrompt("Enter your AWS secret key", "")
+ if err != nil {
+ return nil, err
+ }
+
+ if utils.IsEmptyOrBlank(accessKey) {
+ return nil, fmt.Errorf("please enter a non-empty access key")
+ }
+
+ if utils.IsEmptyOrBlank(secretKey) {
+ return nil, fmt.Errorf("please enter a non-empty secret key")
+ }
+
+ creds, resp, err := service.client.CloudProviderCredentialsAPI.CreateAWSCredentials(context.Background(), organizationID).AwsCredentialsRequest(qovery.AwsCredentialsRequest{
+ AwsStaticCredentialsRequest: &qovery.AwsStaticCredentialsRequest{
+ Type: "AWS_STATIC",
+ Name: credentialsName,
+ AccessKeyId: accessKey,
+ SecretAccessKey: secretKey,
+ },
+ }).Execute()
+ if apiErr := formatCloudProviderCredentialsApiError(resp, err); apiErr != nil {
+ return nil, apiErr
+ }
+ return creds, nil
+
+ case qovery.CLOUDPROVIDERENUM_SCW:
+ accessKey, err := service.promptUiFactory.RunPrompt("Enter your SCW access key", "")
+ if err != nil {
+ return nil, err
+ }
+ secretKey, err := service.promptUiFactory.RunPrompt("Enter your SCW secret key", "")
+ if err != nil {
+ return nil, err
+ }
+ organizationId, err := service.promptUiFactory.RunPrompt("Enter your SCW organization ID", "")
+ if err != nil {
+ return nil, err
+ }
+ projectId, err := service.promptUiFactory.RunPrompt("Enter your SCW project ID", "")
+ if err != nil {
+ return nil, err
+ }
+
+ if utils.IsEmptyOrBlank(accessKey) {
+ return nil, fmt.Errorf("please enter a non-empty access key")
+ }
+
+ if utils.IsEmptyOrBlank(secretKey) {
+ return nil, fmt.Errorf("please enter a non-empty secret key")
+ }
+
+ if utils.IsEmptyOrBlank(organizationId) {
+ return nil, fmt.Errorf("please enter a non-empty organization id")
+ }
+
+ if utils.IsEmptyOrBlank(projectId) {
+ return nil, fmt.Errorf("please enter a non-empty project id")
+ }
+
+ creds, resp, err := service.client.CloudProviderCredentialsAPI.CreateScalewayCredentials(context.Background(), organizationID).ScalewayCredentialsRequest(qovery.ScalewayCredentialsRequest{
+ Name: credentialsName,
+ ScalewayAccessKey: accessKey,
+ ScalewaySecretKey: secretKey,
+ ScalewayProjectId: projectId,
+ ScalewayOrganizationId: organizationId,
+ }).Execute()
+ if apiErr := formatCloudProviderCredentialsApiError(resp, err); apiErr != nil {
+ return nil, apiErr
+ }
+ return creds, nil
+
+ case qovery.CLOUDPROVIDERENUM_GCP:
+ _, gcpCredentialsType, err := service.promptUiFactory.RunSelect("Which GCP credentials type do you want to use?", []string{
+ gcpCredentialsTypeWif,
+ gcpCredentialsTypeServiceAccount,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ var gcpCredentialsRequest qovery.GcpCredentialsRequest
+ switch gcpCredentialsType {
+ case gcpCredentialsTypeWif:
+ serviceAccountEmail, err := service.promptUiFactory.RunPrompt("Enter your GCP service account email", "")
+ if err != nil {
+ return nil, err
+ }
+ workloadIdentityProviderResource, err := service.promptUiFactory.RunPrompt("Enter your GCP Workload Identity provider resource", "")
+ if err != nil {
+ return nil, err
+ }
+ if utils.IsEmptyOrBlank(serviceAccountEmail) {
+ return nil, fmt.Errorf("please enter a non-empty gcp service account email")
+ }
+ if utils.IsEmptyOrBlank(workloadIdentityProviderResource) {
+ return nil, fmt.Errorf("please enter a non-empty gcp workload identity provider resource")
+ }
+
+ gcpCredentialsRequest = qovery.GcpWorkloadIdentityFederationCredentialsRequestAsGcpCredentialsRequest(
+ qovery.NewGcpWorkloadIdentityFederationCredentialsRequest(credentialsName, serviceAccountEmail, workloadIdentityProviderResource),
+ )
+
+ case gcpCredentialsTypeServiceAccount:
+ gcpJsonCredentials, err := service.promptUiFactory.RunPrompt("Enter your GCP JSON credentials (*base64* encoded)", "")
+ if err != nil {
+ return nil, err
+ }
+ if utils.IsEmptyOrBlank(gcpJsonCredentials) {
+ return nil, fmt.Errorf("please enter a non-empty gcp json credentials")
+ }
+
+ gcpServiceAccountKeyRequest := qovery.NewGcpServiceAccountKeyCredentialsRequest(credentialsName, gcpJsonCredentials)
+ gcpCredentialsRequest = qovery.GcpServiceAccountKeyCredentialsRequestAsGcpCredentialsRequest(gcpServiceAccountKeyRequest)
+
+ default:
+ return nil, fmt.Errorf("unhandled gcp credentials type during credentials creation: %s", gcpCredentialsType)
+ }
+
+ creds, resp, err := service.client.CloudProviderCredentialsAPI.CreateGcpCredentials(context.Background(), organizationID).GcpCredentialsRequest(gcpCredentialsRequest).Execute()
+ if apiErr := formatCloudProviderCredentialsApiError(resp, err); apiErr != nil {
+ return nil, apiErr
+ }
+ return creds, nil
+ }
+
+ return nil, fmt.Errorf("unhandled cloud provider type during credentials creation: %s", cloudProviderType)
+}
+
+func formatCloudProviderCredentialsApiError(resp *http.Response, err error) error {
+ if err == nil && (resp == nil || resp.StatusCode < http.StatusBadRequest) {
+ return nil
+ }
+
+ if resp != nil && resp.Body != nil {
+ body, _ := io.ReadAll(resp.Body)
+ if len(body) > 0 {
+ return fmt.Errorf("%s: %v\n%s", color.RedString("Error"), string(body), err)
+ }
+ }
+
+ return fmt.Errorf("%s: %v", color.RedString("Error"), err)
+}
diff --git a/pkg/cluster/credentials/cluster_credentials_service_test.go b/pkg/cluster/credentials/cluster_credentials_service_test.go
new file mode 100644
index 00000000..3c63355e
--- /dev/null
+++ b/pkg/cluster/credentials/cluster_credentials_service_test.go
@@ -0,0 +1,585 @@
+package credentials
+
+import (
+ "errors"
+ "github.com/google/uuid"
+ "github.com/jarcoal/httpmock"
+ "github.com/qovery/qovery-client-go"
+ "github.com/stretchr/testify/assert"
+ "testing"
+
+ "github.com/qovery/qovery-cli/pkg/organization"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func TestCredentialsNameOnCreateCredentials(t *testing.T) {
+ t.Run("Should fail if issue happens when entering credentials name", func(t *testing.T) {
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{
+ "Give a name to your credentials": true,
+ },
+ map[string]string{}),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(uuid.NewString(), qovery.CLOUDPROVIDERENUM_AWS)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "error for prompt 'Give a name to your credentials'", err.Error())
+ })
+ t.Run("Should fail if credentials name entered is empty", func(t *testing.T) {
+ // given
+ var emptyName = ""
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": emptyName,
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(uuid.NewString(), qovery.CLOUDPROVIDERENUM_AWS)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty name for your credentials", err.Error())
+ })
+ t.Run("Should fail if credentials name entered is empty on trim", func(t *testing.T) {
+ // given
+ var emptyOnTrimName = " "
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": emptyOnTrimName,
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(uuid.NewString(), qovery.CLOUDPROVIDERENUM_AWS)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty name for your credentials", err.Error())
+ })
+}
+
+func TestFormatCloudProviderCredentialsApiError(t *testing.T) {
+ t.Run("Should return transport error when response is nil", func(t *testing.T) {
+ // when
+ err := formatCloudProviderCredentialsApiError(nil, errors.New("connection refused"))
+
+ // then
+ assert.NotNil(t, err)
+ assert.Contains(t, err.Error(), "connection refused")
+ })
+}
+
+func TestAwsCredentials(t *testing.T) {
+ t.Run("Should succeed to create AWS credentials according to prompt user inputs", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateAwsCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "aws-credentials",
+ "Enter your AWS access key": "aws-access-key",
+ "Enter your AWS secret key": "aws-secret-key",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_AWS)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, credentials)
+ var createdCredentials = allCredentialsById[credentials.AwsStaticClusterCredentials.Id].(qovery.AwsCredentialsRequest).AwsStaticCredentialsRequest
+ assert.Equal(t, "aws-credentials", createdCredentials.Name)
+ assert.Equal(t, "aws-access-key", createdCredentials.AccessKeyId)
+ assert.Equal(t, "aws-secret-key", createdCredentials.SecretAccessKey)
+ })
+ t.Run("Should fail to create AWS credentials if access key is empty", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateAwsCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "aws-credentials",
+ "Enter your AWS access key": "",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_AWS)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty access key", err.Error())
+ })
+ t.Run("Should fail to create AWS credentials if secret key is empty", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateAwsCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "aws-credentials",
+ "Enter your AWS access key": "aws-access-key",
+ "Enter your AWS secret key": "",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_AWS)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty secret key", err.Error())
+ })
+ t.Run("Should list AWS credentials", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockListCloudProviderCredentials(
+ organization,
+ &qovery.ClusterCredentialsResponseList{Results: []qovery.ClusterCredentials{
+ {AwsStaticClusterCredentials: &qovery.AwsStaticClusterCredentials{Id: "id", Name: "AWS Credentials"}},
+ }},
+ "aws",
+ )
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ var credentials, err = service.ListClusterCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_AWS)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, credentials)
+ })
+}
+
+func TestScalewayCredentials(t *testing.T) {
+ t.Run("Should succeed to create SCW credentials according to prompt user inputs", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateScalewayCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "scaleway-credentials",
+ "Enter your SCW access key": "scw-access-key",
+ "Enter your SCW secret key": "scw-secret-key",
+ "Enter your SCW organization ID": "scw-organization-id",
+ "Enter your SCW project ID": "scw-project-id",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_SCW)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, credentials)
+ var createdCredentials = allCredentialsById[credentials.ScalewayClusterCredentials.Id].(qovery.ScalewayCredentialsRequest)
+ assert.Equal(t, "scaleway-credentials", createdCredentials.Name)
+ assert.Equal(t, "scw-access-key", createdCredentials.ScalewayAccessKey)
+ assert.Equal(t, "scw-secret-key", createdCredentials.ScalewaySecretKey)
+ assert.Equal(t, "scw-organization-id", createdCredentials.ScalewayOrganizationId)
+ assert.Equal(t, "scw-project-id", createdCredentials.ScalewayProjectId)
+ })
+ t.Run("Should fail to create SCW credentials if access key is empty", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateScalewayCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "scaleway-credentials",
+ "Enter your SCW access key": "",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_SCW)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty access key", err.Error())
+ })
+ t.Run("Should fail to create SCW credentials if secret key is empty", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateScalewayCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "scaleway-credentials",
+ "Enter your SCW access key": "scw-access-key",
+ "Enter your SCW secret key": "",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_SCW)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty secret key", err.Error())
+ })
+ t.Run("Should fail to create SCW credentials if organization id is empty", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateScalewayCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "scaleway-credentials",
+ "Enter your SCW access key": "scw-access-key",
+ "Enter your SCW secret key": "scw-secret-key",
+ "Enter your SCW organization ID": "",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_SCW)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty organization id", err.Error())
+ })
+ t.Run("Should fail to create SCW credentials if project id is empty", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateScalewayCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "scaleway-credentials",
+ "Enter your SCW access key": "scw-access-key",
+ "Enter your SCW secret key": "scw-secret-key",
+ "Enter your SCW organization ID": "scw-organization-id",
+ "Enter your SCW project ID": "",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_SCW)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty project id", err.Error())
+ })
+ t.Run("Should list SCW credentials", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockListCloudProviderCredentials(
+ organization,
+ &qovery.ClusterCredentialsResponseList{Results: []qovery.ClusterCredentials{
+ {ScalewayClusterCredentials: &qovery.ScalewayClusterCredentials{Id: "id", Name: "AWS Credentials"}},
+ }},
+ "scaleway",
+ )
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ var credentials, err = service.ListClusterCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_SCW)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, credentials)
+ })
+}
+
+func TestGcpCredentials(t *testing.T) {
+ t.Run("Should succeed to create GCP service account JSON key credentials according to prompt user inputs", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateGcpCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "gcp-credentials",
+ "Which GCP credentials type do you want to use?": gcpCredentialsTypeServiceAccount,
+ "Enter your GCP JSON credentials (*base64* encoded)": "gcp-creds-json",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_GCP)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, credentials)
+ var createdCredentials = allCredentialsById[credentials.GenericClusterCredentials.Id].(qovery.GcpCredentialsRequest)
+ assert.NotNil(t, createdCredentials.GcpServiceAccountKeyCredentialsRequest)
+ assert.Equal(t, "gcp-credentials", createdCredentials.GcpServiceAccountKeyCredentialsRequest.Name)
+ assert.Equal(t, "gcp-creds-json", createdCredentials.GcpServiceAccountKeyCredentialsRequest.GcpCredentials)
+ })
+ t.Run("Should succeed to create GCP workload identity federation credentials according to prompt user inputs", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateGcpCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "gcp-wif-credentials",
+ "Which GCP credentials type do you want to use?": gcpCredentialsTypeWif,
+ "Enter your GCP service account email": "svc@example.iam.gserviceaccount.com",
+ "Enter your GCP Workload Identity provider resource": "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_GCP)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, credentials)
+ var createdCredentials = allCredentialsById[credentials.GenericClusterCredentials.Id].(qovery.GcpCredentialsRequest)
+ assert.NotNil(t, createdCredentials.GcpWorkloadIdentityFederationCredentialsRequest)
+ assert.Equal(t, "gcp-wif-credentials", createdCredentials.GcpWorkloadIdentityFederationCredentialsRequest.Name)
+ assert.Equal(t, "svc@example.iam.gserviceaccount.com", createdCredentials.GcpWorkloadIdentityFederationCredentialsRequest.ServiceAccountEmail)
+ assert.Equal(t, "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", createdCredentials.GcpWorkloadIdentityFederationCredentialsRequest.WorkloadIdentityProviderResource)
+ })
+ t.Run("Should fail to create GCP credentials if json is empty", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateGcpCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "gcp-credentials",
+ "Which GCP credentials type do you want to use?": gcpCredentialsTypeServiceAccount,
+ "Enter your GCP JSON credentials (*base64* encoded)": "",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_GCP)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty gcp json credentials", err.Error())
+ })
+ t.Run("Should fail to create GCP workload identity federation credentials if service account email is empty", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateGcpCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "gcp-wif-credentials",
+ "Which GCP credentials type do you want to use?": gcpCredentialsTypeWif,
+ "Enter your GCP service account email": "",
+ "Enter your GCP Workload Identity provider resource": "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_GCP)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty gcp service account email", err.Error())
+ })
+ t.Run("Should fail to create GCP workload identity federation credentials if provider resource is empty", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockCreateGcpCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Give a name to your credentials": "gcp-wif-credentials",
+ "Which GCP credentials type do you want to use?": gcpCredentialsTypeWif,
+ "Enter your GCP service account email": "svc@example.iam.gserviceaccount.com",
+ "Enter your GCP Workload Identity provider resource": "",
+ }),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_GCP)
+
+ // then
+ assert.Nil(t, credentials)
+ assert.NotNil(t, err)
+ assert.Equal(t, "please enter a non-empty gcp workload identity provider resource", err.Error())
+ })
+ t.Run("Should list GCP credentials", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockListCloudProviderCredentials(
+ organization,
+ &qovery.ClusterCredentialsResponseList{Results: []qovery.ClusterCredentials{
+ {GenericClusterCredentials: &qovery.GenericClusterCredentials{Id: "id", Name: "AWS Credentials"}},
+ }},
+ "gcp",
+ )
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ var credentials, err = service.ListClusterCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_GCP)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, credentials)
+ })
+}
+
+func TestOnPremiseOnCreateCredentials(t *testing.T) {
+ t.Run("Should create automatically credentials with name 'on-premise' when creating on premise cluster credentials", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockOnPremiseCreateCredentials(organization)
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_ON_PREMISE)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, credentials)
+ var createdCredentials = allCredentialsById[credentials.GenericClusterCredentials.Id].(qovery.OnPremiseCredentialsRequest)
+ assert.Equal(t, "on-premise", createdCredentials.Name)
+ })
+ t.Run("Should list On Premise credentials", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mock
+ var organization = organization.CreateTestOrganization()
+ MockListCloudProviderCredentials(
+ organization,
+ &qovery.ClusterCredentialsResponseList{Results: []qovery.ClusterCredentials{
+ {GenericClusterCredentials: &qovery.GenericClusterCredentials{Id: "id", Name: "On Premise Credentials"}},
+ }},
+ "onPremise",
+ )
+
+ // given
+ var service = NewClusterCredentialsService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ var credentials, err = service.ListClusterCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_ON_PREMISE)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, credentials)
+ })
+}
diff --git a/pkg/cluster/selfmanaged/install_self_managed_cluster_service.go b/pkg/cluster/selfmanaged/install_self_managed_cluster_service.go
new file mode 100644
index 00000000..2b03de16
--- /dev/null
+++ b/pkg/cluster/selfmanaged/install_self_managed_cluster_service.go
@@ -0,0 +1,475 @@
+package selfmanaged
+
+import (
+ "fmt"
+ "github.com/qovery/qovery-client-go"
+ "gopkg.in/yaml.v3"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/qovery/qovery-cli/pkg/cluster"
+ "github.com/qovery/qovery-cli/pkg/filewriter"
+ "github.com/qovery/qovery-cli/pkg/organization"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+type InstallSelfManagedClusterService struct {
+ organizationService organization.OrganizationService
+ selfManagedClusterService SelfManagedClusterService
+ clusterService cluster.ClusterService
+ fileWriterService filewriter.FileWriterService
+ promptUiFactory promptuifactory.PromptUiFactory
+}
+
+func NewInstallSelfManagedClusterService(
+ organizationService organization.OrganizationService,
+ selfManagedClusterService SelfManagedClusterService,
+ clusterService cluster.ClusterService,
+ fileWriterService filewriter.FileWriterService,
+ promptUiFactory promptuifactory.PromptUiFactory,
+) *InstallSelfManagedClusterService {
+ return &InstallSelfManagedClusterService{
+ organizationService,
+ selfManagedClusterService,
+ clusterService,
+ fileWriterService,
+ promptUiFactory,
+ }
+}
+
+// InstallCluster
+// Returns either an error or an indication printed by the caller
+func (service *InstallSelfManagedClusterService) InstallCluster() (*string, error) {
+ utils.Println("")
+ utils.PrintlnInfo(`The following procedure allows you to generate the values files and the helm command necessary to install Qovery on your cluster. You can find more information on our public documentation: https://hub.qovery.com/docs/getting-started/install-qovery/kubernetes/quickstart/`)
+ cloudProviderPairList := []struct {
+ Name string
+ Value qovery.CloudVendorEnum
+ }{
+ {"Your AWS EKS cluster", qovery.CLOUDVENDORENUM_AWS},
+ {"Your GCP GKE cluster", qovery.CLOUDVENDORENUM_GCP},
+ {"Your Scaleway Kapsule cluster", qovery.CLOUDVENDORENUM_SCW},
+ {"Your Azure AKS cluster", qovery.CLOUDVENDORENUM_AZURE},
+ {"Your OVH kube cluster", qovery.CLOUDVENDORENUM_OVH},
+ {"Your Digital Ocean kube cluster", qovery.CLOUDVENDORENUM_DO},
+ {"Your Oracle Cloud kube cluster", qovery.CLOUDVENDORENUM_ORACLE},
+ {"Your Hetzner kube cluster", qovery.CLOUDVENDORENUM_HETZNER},
+ {"Your IBM Cloud kube cluster", qovery.CLOUDVENDORENUM_IBM},
+ {"Your Civo K3S cluster", qovery.CLOUDVENDORENUM_CIVO},
+ {"Your Local Machine", qovery.CLOUDVENDORENUM_ON_PREMISE},
+ {"Other", qovery.CLOUDVENDORENUM_ON_PREMISE},
+ }
+
+ utils.Println("Cluster Type:")
+ keys := make([]string, len(cloudProviderPairList))
+ for i, pair := range cloudProviderPairList {
+ keys[i] = pair.Name
+ }
+ _, kubernetesType, err := service.promptUiFactory.RunSelectWithSize("Select where you want to install Qovery on",
+ keys,
+ len(keys),
+ )
+ if err != nil {
+ return nil, err
+ }
+ if strings.Contains(kubernetesType, "Local Machine") {
+ indicationMessage := "Please use `qovery demo up` to create a demo cluster on your local machine"
+ return &indicationMessage, nil
+ }
+ cloudVendor := getCloudVendor(cloudProviderPairList, kubernetesType)
+ organization, err := service.organizationService.AskUserToSelectOrganization()
+ if err != nil {
+ return nil, err
+ }
+ if organization == nil {
+ return nil, fmt.Errorf("organization not found, please create one on https://console.qovery.com")
+ }
+
+ // List cluster and if there is one that already exist for self-managed and this cloud provider
+ // propose to re-use it
+ clusters, err := service.clusterService.ListClusters(organization.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ var selfManagedClusters []qovery.Cluster
+ for _, cluster := range clusters.GetResults() {
+ if *cluster.Kubernetes == qovery.KUBERNETESENUM_SELF_MANAGED && cluster.CloudProvider == cloudVendor {
+ selfManagedClusters = append(selfManagedClusters, cluster)
+ }
+ }
+
+ var cluster *qovery.Cluster
+ if len(selfManagedClusters) > 0 {
+ // if a self-managed cluster exists, then propose to reuse it or create a new one
+ utils.Println("You already have self-managed clusters in your organization.")
+ utils.Println("Do you want to reuse one of them or create a new one?")
+
+ _, reuseAClusterPrompt, err := service.promptUiFactory.RunSelect("Reuse or Create a new cluster?", []string{"Reuse a Cluster", "Create a new cluster"})
+ if err != nil {
+ return nil, err
+ }
+
+ if reuseAClusterPrompt == "Reuse a Cluster" {
+ utils.Println("Select the cluster you want to reuse:")
+
+ var clusterNameItems []string
+ for _, cluster := range selfManagedClusters {
+ clusterNameItems = append(clusterNameItems, cluster.Name)
+ }
+
+ _, reuseClusterName, err := service.promptUiFactory.RunSelectWithSize("Select the cluster you want to reuse:", clusterNameItems, 10)
+
+ if err != nil {
+ return nil, err
+ }
+
+ cluster = utils.FindByClusterName(selfManagedClusters, reuseClusterName)
+ }
+ }
+
+ // We need to create & configure the cluster
+ if cluster == nil {
+ createdCluster, err := service.selfManagedClusterService.Create(organization.ID, cloudVendor)
+ if err != nil {
+ return nil, err
+ }
+ cluster = createdCluster
+ err = service.selfManagedClusterService.Configure(cluster)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // Email selection for certificate for cert manager
+ utils.Println("Contact email for Let's Encrypt certificate:")
+ email, err := service.promptUiFactory.RunPrompt("Enter your email address to receive expiration notification from Let's Encrypt", "acme@qovery.com")
+ if err != nil {
+ return nil, err
+ }
+
+ // get the values file for the cluster
+ resultClusterHelmValuesContent, err := service.selfManagedClusterService.GetInstallationHelmValues(organization.ID, cluster.Id)
+ if err != nil {
+ return nil, err
+ }
+ helmValues := *resultClusterHelmValuesContent
+
+ // inject the email for Cert Manager
+ helmValues = strings.ReplaceAll(helmValues, "acme@qovery.com", email)
+ helmValues = fmt.Sprintf("%s\n", helmValues)
+
+ // trim lines if they start with "qovery:" or if they contain "set-by-customer"
+ qoveryHelmValues, err := service.selfManagedClusterService.GetBaseHelmValuesContent(mapCloudVendorToCloudProviderType(cloudVendor))
+ if err != nil {
+ return nil, err
+ }
+
+ helmValues += stripQoverySection(*qoveryHelmValues)
+
+ if strings.Contains(kubernetesType, "Azure") {
+ contentWithAKSValues, err := injectAzureAKSValues(helmValues)
+ if err != nil {
+ return nil, err
+ }
+ helmValues = *contentWithAKSValues
+ }
+
+ contentWithGatewayDomain, err := injectQoveryClusterGatewayDomain(helmValues)
+ if err != nil {
+ return nil, err
+ }
+ helmValues = *contentWithGatewayDomain
+
+ contentWithExternalDNSGatewaySources, err := injectExternalDNSGatewaySources(helmValues)
+ if err != nil {
+ return nil, err
+ }
+ helmValues = *contentWithExternalDNSGatewaySources
+
+ contentWithEnvoyIngress, err := injectEnvoyIngressServices(helmValues)
+ if err != nil {
+ return nil, err
+ }
+ helmValues = *contentWithEnvoyIngress
+
+ // generate the helm values file and output it to the user to ./values-.yaml
+ helmValuesFileName := fmt.Sprintf("values-%s.yaml", strings.ToLower(cluster.Name))
+
+ // get current working directory
+ dir, err := os.Getwd()
+
+ if err != nil {
+ return nil, err
+ }
+
+ helmValuesFileName = filepath.Join(dir, helmValuesFileName)
+
+ helmValuesFileName, err = service.promptUiFactory.RunPrompt("File path to save Helm Values to", helmValuesFileName)
+
+ if err != nil {
+ return nil, err
+ }
+
+ err = service.fileWriterService.WriteFile(helmValuesFileName, []byte(helmValues), 0644)
+
+ if err != nil {
+ return nil, err
+ }
+
+ outputCommandsToInstallQoveryOnCluster(helmValuesFileName)
+
+ return nil, nil
+}
+
+func getCloudVendor(list []struct {
+ Name string
+ Value qovery.CloudVendorEnum
+}, kubernetesType string) qovery.CloudVendorEnum {
+ for _, pair := range list {
+ if pair.Name == kubernetesType {
+ return pair.Value
+ }
+ }
+ return qovery.CLOUDVENDORENUM_ON_PREMISE
+}
+
+func stripQoverySection(qoveryHelmValues string) string {
+ // Erase the qovery: yaml section to replace it with correct fetched values for this cluster
+ // We can't use yaml parser here, because the yaml file contains anchor (&toto *toto) and parsing it will cause those
+ // anchors to be replaced with the incorrect values...
+ re := regexp.MustCompile("(?m)^qovery:\n( .*\n)+")
+ return re.ReplaceAllString(qoveryHelmValues, "")
+}
+
+func outputCommandsToInstallQoveryOnCluster(helmValuesFileName string) {
+ // give instruction to the user to install the cluster
+ utils.Println("")
+ utils.Println("////////////////////////////////////////////////////////////////////////////////////")
+ utils.Println("//// Follow these instructions to install your cluster ////")
+ utils.Println("////////////////////////////////////////////////////////////////////////////////////")
+ utils.Println(`
+# Add the Qovery Helm repository
+helm repo add qovery https://helm.qovery.com`)
+ utils.Println("helm repo update")
+
+ utils.Println(fmt.Sprintf(`
+# Verify the helm values
+Qovery provides you with a default configuration that can be customized based on your needs. More information here: https://hub.qovery.com/docs/getting-started/install-qovery/kubernetes/byok-config
+Helm values location: %s
+ `, helmValuesFileName))
+
+ utils.Println(`
+# Pre-apply Gateway API and Envoy CRDs before the main Helm release.
+# The CRD bundle is too large to fit reliably in Helm's release Secret storage.
+helm pull qovery/qovery --untar --untardir /tmp/qovery-helm-chart
+helm template qovery-gateway-crds /tmp/qovery-helm-chart/qovery/charts/gateway-crds-helm \
+ --set crds.gatewayAPI.enabled=true \
+ --set crds.gatewayAPI.channel=standard \
+ --set crds.envoyGateway.enabled=true | kubectl apply --server-side -f -
+kubectl wait --for=condition=Established --timeout=180s crd/gateways.gateway.networking.k8s.io
+kubectl wait --for=condition=Established --timeout=180s crd/envoyproxies.gateway.envoyproxy.io`)
+
+ utils.Println(`
+# Note: --rollback-on-failure requires Helm >= 3.15.0 (replaces the deprecated --atomic flag).
+# Check your version with: helm version`)
+
+ utils.Println(fmt.Sprintf(`
+# Install Qovery on your cluster first, without some services to avoid circular dependency errors.
+helm upgrade --install --create-namespace -n qovery -f "%s" --rollback-on-failure \
+ --set services.certificates.cert-manager-configs.enabled=false \
+ --set services.certificates.qovery-cert-manager-webhook.enabled=false \
+ --set services.ingress.envoy-gateway-crd.enabled=false \
+ --set qovery-cluster-gateway.metrics.enabled=false \
+ --set qovery-cluster-gateway.metrics.podMonitor.enabled=false \
+ --set services.ingress.envoy-gateway.enabled=false \
+ --set services.ingress.qovery-gateway-class.enabled=false \
+ --set services.ingress.qovery-cluster-gateway.enabled=false \
+ --set services.qovery.qovery-cluster-agent.enabled=false \
+ --set services.qovery.qovery-engine.enabled=false \
+ --set services.qovery.qovery-operator.enabled=false \
+ qovery qovery/qovery`, helmValuesFileName))
+
+ utils.Println(fmt.Sprintf(`
+# Then, re-apply the Qovery installation with the remaining services
+helm upgrade --install --create-namespace -n qovery -f "%s" --wait --rollback-on-failure \
+ --set services.ingress.envoy-gateway-crd.enabled=false \
+ --set qovery-cluster-gateway.metrics.enabled=false \
+ --set qovery-cluster-gateway.metrics.podMonitor.enabled=false \
+ --set services.qovery.qovery-operator.enabled=false \
+ qovery qovery/qovery
+`, helmValuesFileName))
+ utils.Println("////////////////////////////////////////////////////////////////////////////////////")
+ utils.PrintlnInfo("Please note that the installation process may take a few minutes to complete.")
+}
+
+func injectAzureAKSValues(clusterHelmValuesContent string) (*string, error) {
+ // convert the clusterHelmValuesContent into a YAML object and into a map
+ var helmValuesYaml map[string]interface{}
+
+ err := yaml.Unmarshal([]byte(clusterHelmValuesContent), &helmValuesYaml)
+
+ if err != nil {
+ return nil, err
+ }
+
+ ingressNginx := helmValuesYaml["ingress-nginx"].(map[string]interface{})
+ ingressNginxController := ingressNginx["controller"].(map[string]interface{})
+
+ // inject the Azure AKS values
+ if ingressNginxController["service"] == nil {
+ ingressNginxController["service"] = map[string]interface{}{
+ "externalTrafficPolicy": "Local",
+ "annotations": map[string]interface{}{
+ "service.beta.kubernetes.io/azure-load-balancer-internal": "false",
+ },
+ }
+ } else {
+ ingressNginxControllerService := ingressNginxController["service"].(map[string]interface{})
+ ingressNginxControllerService["externalTrafficPolicy"] = "Local"
+
+ if ingressNginxControllerService["annotations"] == nil {
+ ingressNginxControllerService["annotations"] = map[string]interface{}{
+ "service.beta.kubernetes.io/azure-load-balancer-internal": "false",
+ }
+ } else {
+ ingressNginxControllerServiceAnnotations := ingressNginxControllerService["annotations"].(map[string]interface{})
+ ingressNginxControllerServiceAnnotations["service.beta.kubernetes.io/azure-load-balancer-internal"] = "false"
+ }
+ }
+
+ helmValuesYamlBytes, err := yaml.Marshal(helmValuesYaml)
+
+ if err != nil {
+ return nil, err
+ }
+ helmValuesString := string(helmValuesYamlBytes)
+ return &helmValuesString, nil
+}
+
+func injectQoveryClusterGatewayDomain(clusterHelmValuesContent string) (*string, error) {
+ var helmValuesYaml map[string]interface{}
+
+ err := yaml.Unmarshal([]byte(clusterHelmValuesContent), &helmValuesYaml)
+ if err != nil {
+ // Keep backward-compatible behavior for tests or mocked flows that do not provide valid YAML.
+ return &clusterHelmValuesContent, nil
+ }
+
+ qoveryValues, ok := helmValuesYaml["qovery"].(map[string]interface{})
+ if !ok {
+ return &clusterHelmValuesContent, nil
+ }
+
+ qoveryDomain, ok := qoveryValues["domain"].(string)
+ if !ok || strings.TrimSpace(qoveryDomain) == "" {
+ return &clusterHelmValuesContent, nil
+ }
+
+ qoveryClusterGateway, ok := helmValuesYaml["qovery-cluster-gateway"].(map[string]interface{})
+ if !ok {
+ qoveryClusterGateway = map[string]interface{}{}
+ helmValuesYaml["qovery-cluster-gateway"] = qoveryClusterGateway
+ }
+
+ dnsValues, ok := qoveryClusterGateway["dns"].(map[string]interface{})
+ if !ok {
+ dnsValues = map[string]interface{}{}
+ qoveryClusterGateway["dns"] = dnsValues
+ }
+
+ if existingDomain, ok := dnsValues["domain"].(string); ok && strings.TrimSpace(existingDomain) != "" {
+ return &clusterHelmValuesContent, nil
+ }
+
+ dnsValues["domain"] = qoveryDomain
+
+ helmValuesYamlBytes, err := yaml.Marshal(helmValuesYaml)
+ if err != nil {
+ return nil, err
+ }
+
+ helmValuesString := string(helmValuesYamlBytes)
+ return &helmValuesString, nil
+}
+
+func injectExternalDNSGatewaySources(clusterHelmValuesContent string) (*string, error) {
+ var helmValuesYaml map[string]interface{}
+
+ err := yaml.Unmarshal([]byte(clusterHelmValuesContent), &helmValuesYaml)
+ if err != nil {
+ return &clusterHelmValuesContent, nil
+ }
+
+ externalDNSValues, ok := helmValuesYaml["external-dns"].(map[string]interface{})
+ if !ok {
+ return &clusterHelmValuesContent, nil
+ }
+
+ if _, ok := externalDNSValues["sources"]; !ok {
+ externalDNSValues["sources"] = []string{
+ "service",
+ "ingress",
+ "gateway-httproute",
+ "gateway-grpcroute",
+ }
+ }
+
+ if _, ok := externalDNSValues["enableGatewayListenerSets"]; !ok {
+ externalDNSValues["enableGatewayListenerSets"] = true
+ }
+
+ helmValuesYamlBytes, err := yaml.Marshal(helmValuesYaml)
+ if err != nil {
+ return nil, err
+ }
+
+ helmValuesString := string(helmValuesYamlBytes)
+ return &helmValuesString, nil
+}
+
+func injectEnvoyIngressServices(clusterHelmValuesContent string) (*string, error) {
+ var helmValuesYaml map[string]interface{}
+
+ err := yaml.Unmarshal([]byte(clusterHelmValuesContent), &helmValuesYaml)
+ if err != nil {
+ return &clusterHelmValuesContent, nil
+ }
+
+ servicesValues, ok := helmValuesYaml["services"].(map[string]interface{})
+ if !ok {
+ return &clusterHelmValuesContent, nil
+ }
+
+ ingressValues, ok := servicesValues["ingress"].(map[string]interface{})
+ if !ok {
+ ingressValues = map[string]interface{}{}
+ servicesValues["ingress"] = ingressValues
+ }
+
+ ensureServiceEnabled := func(serviceName string, enabled bool) {
+ serviceValues, ok := ingressValues[serviceName].(map[string]interface{})
+ if !ok {
+ serviceValues = map[string]interface{}{}
+ ingressValues[serviceName] = serviceValues
+ }
+ serviceValues["enabled"] = enabled
+ }
+
+ ensureServiceEnabled("ingress-nginx", false)
+ ensureServiceEnabled("envoy-gateway-crd", false)
+ ensureServiceEnabled("envoy-gateway", true)
+ ensureServiceEnabled("qovery-gateway-class", true)
+ ensureServiceEnabled("qovery-cluster-gateway", true)
+
+ helmValuesYamlBytes, err := yaml.Marshal(helmValuesYaml)
+ if err != nil {
+ return nil, err
+ }
+
+ helmValuesString := string(helmValuesYamlBytes)
+ return &helmValuesString, nil
+}
diff --git a/pkg/cluster/selfmanaged/install_self_managed_cluster_service_test.go b/pkg/cluster/selfmanaged/install_self_managed_cluster_service_test.go
new file mode 100644
index 00000000..d9a36b15
--- /dev/null
+++ b/pkg/cluster/selfmanaged/install_self_managed_cluster_service_test.go
@@ -0,0 +1,801 @@
+package selfmanaged
+
+import (
+ "errors"
+ "github.com/qovery/qovery-client-go"
+ "github.com/stretchr/testify/assert"
+ "gopkg.in/yaml.v3"
+ "io"
+ "os"
+ "testing"
+
+ "github.com/qovery/qovery-cli/pkg/cluster"
+ "github.com/qovery/qovery-cli/pkg/filewriter"
+ "github.com/qovery/qovery-cli/pkg/organization"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+)
+
+func captureStdout(t *testing.T, fn func()) string {
+ t.Helper()
+
+ oldOut := os.Stdout
+ defer func() {
+ os.Stdout = oldOut
+ }()
+
+ rOut, wOut, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("create stdout pipe: %v", err)
+ }
+
+ os.Stdout = wOut
+ fn()
+ _ = wOut.Close()
+
+ outBuf, err := io.ReadAll(rOut)
+ if err != nil {
+ t.Fatalf("read captured stdout: %v", err)
+ }
+
+ return string(outBuf)
+}
+
+func TestInstallNewCluster(t *testing.T) {
+ t.Run("Should return an information message when attempting to create cluster on Local Machine", func(t *testing.T) {
+ // given
+ var organizationService = organization.OrganizationServiceMock{}
+ var selfManagedService = SelfManagedClusterServiceMock{}
+ var clusterService = cluster.ClusterServiceMock{}
+ var fileWriterService = filewriter.FileWriterServiceMock{}
+ var service = NewInstallSelfManagedClusterService(
+ &organizationService,
+ &selfManagedService,
+ &clusterService,
+ &fileWriterService,
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{},
+ map[string]string{
+ "Select where you want to install Qovery on": "Your Local Machine",
+ },
+ ),
+ )
+
+ // when
+ var informationMessage, err = service.InstallCluster()
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, informationMessage)
+ assert.Equal(t, *informationMessage, "Please use `qovery demo up` to create a demo cluster on your local machine")
+ })
+ t.Run("Should succeed to create a new self managed cluster", func(t *testing.T) {
+ // given
+ var testOrganization = organization.CreateTestOrganization()
+ var organizationService = organization.OrganizationServiceMock{
+ ResultAskUserToSelectOrganization: func() (*organization.OrganizationDto, error) {
+ return &organization.OrganizationDto{ID: testOrganization.Id, Name: testOrganization.Name}, nil
+ },
+ }
+ var selfManagedService = SelfManagedClusterServiceMock{
+ ResultCreate: func(organizationId string, cloudVendor qovery.CloudVendorEnum) (*qovery.Cluster, error) {
+ return CreateSelfManagedTestCluster(testOrganization, cloudVendor), nil
+ },
+ ResultConfigure: func() error {
+ return nil
+ },
+ ResultGetBaseHelmValuesContent: func(kubernetesType qovery.CloudProviderEnum) (*string, error) {
+ s := "")
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
diff --git a/pkg/cluster/selfmanaged/self_managed_cluster_service.go b/pkg/cluster/selfmanaged/self_managed_cluster_service.go
new file mode 100644
index 00000000..963685b9
--- /dev/null
+++ b/pkg/cluster/selfmanaged/self_managed_cluster_service.go
@@ -0,0 +1,330 @@
+package selfmanaged
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/fatih/color"
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/pkg/cluster"
+ "github.com/qovery/qovery-cli/pkg/cluster/containerregistry"
+ "github.com/qovery/qovery-cli/pkg/cluster/credentials"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+type SelfManagedClusterService interface {
+ Create(organizationID string, cloudVendor qovery.CloudVendorEnum) (*qovery.Cluster, error)
+ Configure(cluster *qovery.Cluster) error
+ GetInstallationHelmValues(organizationId string, clusterId string) (*string, error)
+ GetBaseHelmValuesContent(kubernetesType qovery.CloudProviderEnum) (*string, error)
+}
+
+type SelfManagedClusterServiceImpl struct {
+ client *qovery.APIClient
+ clusterService cluster.ClusterService
+ clusterCredentialsService credentials.ClusterCredentialsService
+ clusterContainerRegistryService containerregistry.ClusterContainerRegistryService
+ promptUiFactory promptuifactory.PromptUiFactory
+ localBaseHelmValuesPath string
+}
+
+func NewSelfManagedClusterService(
+ client *qovery.APIClient,
+ clusterService cluster.ClusterService,
+ clusterCredentialsService credentials.ClusterCredentialsService,
+ clusterContainerRegistryService containerregistry.ClusterContainerRegistryService,
+ promptUiFactory promptuifactory.PromptUiFactory,
+ localBaseHelmValuesPath ...string,
+) *SelfManagedClusterServiceImpl {
+ baseHelmValuesPath := ""
+ if len(localBaseHelmValuesPath) > 0 {
+ baseHelmValuesPath = localBaseHelmValuesPath[0]
+ }
+
+ return &SelfManagedClusterServiceImpl{
+ client,
+ clusterService,
+ clusterCredentialsService,
+ clusterContainerRegistryService,
+ promptUiFactory,
+ baseHelmValuesPath,
+ }
+}
+
+func (service *SelfManagedClusterServiceImpl) Create(
+ organizationID string,
+ cloudVendor qovery.CloudVendorEnum,
+) (*qovery.Cluster, error) {
+ cloudProviderType := mapCloudVendorToCloudProviderType(cloudVendor)
+ clusterRegion, err := service.findClusterRegion(cloudProviderType)
+ if err != nil {
+ return nil, err
+ }
+
+ credentials, err := service.findOrCreateCredentials(organizationID, cloudProviderType)
+ if err != nil {
+ return nil, err
+ }
+
+ newClusterName, err := service.promptUiFactory.RunPrompt("Give a name to your new cluster", "my-cluster")
+ if err != nil {
+ return nil, err
+ }
+
+ selfManagedMode := qovery.KUBERNETESENUM_SELF_MANAGED
+ credentialsId, err := getId(credentials)
+ if err != nil {
+ return nil, err
+ }
+ credentialsName, err := getName(credentials)
+ if err != nil {
+ return nil, err
+ }
+ cluster, resp, err := service.client.ClustersAPI.CreateCluster(context.Background(), organizationID).ClusterRequest(qovery.ClusterRequest{
+ Name: newClusterName,
+ Region: *clusterRegion,
+ CloudProvider: cloudVendor,
+ Kubernetes: &selfManagedMode,
+ CloudProviderCredentials: &qovery.ClusterCloudProviderInfoRequest{
+ CloudProvider: &cloudProviderType,
+ Credentials: &qovery.ClusterCloudProviderInfoCredentials{Id: &credentialsId, Name: &credentialsName},
+ Region: clusterRegion,
+ },
+ Features: []qovery.ClusterRequestFeaturesInner{},
+ }).Execute()
+
+ if err != nil {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("%s: %v", color.RedString("Error"), string(body))
+ }
+
+ return cluster, nil
+}
+
+func (service *SelfManagedClusterServiceImpl) Configure(cluster *qovery.Cluster) error {
+ // early return for cluster types != On Premise
+ if mapCloudVendorToCloudProviderType(cluster.CloudProvider) != qovery.CLOUDPROVIDERENUM_ON_PREMISE {
+ return nil
+ }
+
+ err := service.clusterContainerRegistryService.AskToEditClusterContainerRegistry(cluster.Organization.Id, cluster.Id)
+ if err != nil {
+ return err
+ }
+
+ err = service.clusterService.AskToEditStorageClass(cluster)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (service *SelfManagedClusterServiceImpl) findClusterRegion(
+ cloudProviderType qovery.CloudProviderEnum,
+) (*string, error) {
+ // Early return if we use a ON_PREMISE cluster type
+ if cloudProviderType == qovery.CLOUDPROVIDERENUM_ON_PREMISE {
+ onPrem := "on-premise"
+ return &onPrem, nil
+ }
+
+ // Normal path
+ clusterRegions, err := service.clusterService.ListClusterRegions(cloudProviderType)
+ if err != nil {
+ return nil, err
+ }
+
+ var items []string
+ for _, item := range clusterRegions.Results {
+ items = append(items, item.Name)
+ }
+
+ utils.Println("Cluster Region:")
+ ix, _, err := service.promptUiFactory.RunSelectWithSizeAndSearcher(
+ "Select the region where your cluster is installed",
+ items,
+ 30,
+ func(input string, index int) bool {
+ return strings.Contains(items[index], input)
+ },
+ )
+
+ if err != nil {
+ return nil, err
+ }
+ return &clusterRegions.Results[ix].Name, nil
+}
+
+func (service *SelfManagedClusterServiceImpl) findOrCreateCredentials(
+ organizationID string,
+ cloudProviderType qovery.CloudProviderEnum,
+) (*qovery.ClusterCredentials, error) {
+ clusterCreds, err := service.clusterCredentialsService.ListClusterCredentials(organizationID, cloudProviderType)
+ if err != nil {
+ return nil, err
+ }
+
+ var ix = math.MaxInt
+ if cloudProviderType == qovery.CLOUDPROVIDERENUM_ON_PREMISE {
+ if len(clusterCreds.Results) > 0 {
+ ix = 0
+ }
+ } else {
+ var items []string
+ for _, creds := range clusterCreds.Results {
+ name, err := getName(&creds)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, name)
+ }
+ items = append(items, "Create new credentials")
+
+ utils.Println("Cluster registry credentials:")
+ ixx, _, err := service.promptUiFactory.RunSelectWithSize(
+ "Which credentials do you want to use for the container registry ? A container registry is necessary to build and mirror the images deployed on your cluster.",
+ items,
+ 10,
+ )
+ if err != nil {
+ return nil, err
+ }
+ ix = ixx
+ }
+
+ if ix >= len(clusterCreds.Results) {
+ return service.clusterCredentialsService.AskToCreateCredentials(organizationID, cloudProviderType)
+ }
+
+ return &clusterCreds.Results[ix], nil
+}
+
+func (service *SelfManagedClusterServiceImpl) GetInstallationHelmValues(organizationId string, clusterId string) (*string, error) {
+ clusterHelmValuesContent, resp, err := service.client.ClustersAPI.GetInstallationHelmValues(
+ context.Background(),
+ organizationId,
+ clusterId,
+ ).Execute()
+
+ if err != nil {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("%s: %v", color.RedString("Error"), string(body))
+ }
+
+ return &clusterHelmValuesContent, nil
+}
+
+func getName(creds *qovery.ClusterCredentials) (string, error) {
+ switch castedCreds := creds.GetActualInstance().(type) {
+ case *qovery.AwsStaticClusterCredentials:
+ return castedCreds.GetName(), nil
+ case *qovery.AwsRoleClusterCredentials:
+ return castedCreds.GetName(), nil
+ case *qovery.ScalewayClusterCredentials:
+ return castedCreds.GetName(), nil
+ case *qovery.GcpStaticClusterCredentials:
+ return castedCreds.GetName(), nil
+ case *qovery.GcpWorkloadIdentityFederationClusterCredentials:
+ return castedCreds.GetName(), nil
+ case *qovery.GenericClusterCredentials:
+ return castedCreds.GetName(), nil
+ default:
+ return "", errors.New("unknown credentials type")
+ }
+}
+
+func getId(creds *qovery.ClusterCredentials) (string, error) {
+ switch castedCreds := creds.GetActualInstance().(type) {
+ case *qovery.AwsStaticClusterCredentials:
+ return castedCreds.GetId(), nil
+ case *qovery.AwsRoleClusterCredentials:
+ return castedCreds.GetId(), nil
+ case *qovery.ScalewayClusterCredentials:
+ return castedCreds.GetId(), nil
+ case *qovery.GcpStaticClusterCredentials:
+ return castedCreds.GetId(), nil
+ case *qovery.GcpWorkloadIdentityFederationClusterCredentials:
+ return castedCreds.GetId(), nil
+ case *qovery.GenericClusterCredentials:
+ return castedCreds.GetId(), nil
+ default:
+ return "", errors.New("unknown credentials type")
+ }
+}
+
+func (service *SelfManagedClusterServiceImpl) GetBaseHelmValuesContent(kubernetesType qovery.CloudProviderEnum) (*string, error) {
+ if service.localBaseHelmValuesPath != "" {
+ body, err := os.ReadFile(service.localBaseHelmValuesPath)
+ if err != nil {
+ return nil, err
+ }
+
+ s := string(body)
+ return &s, nil
+ }
+
+ // download the appropriate values file
+ valuesUrl := ""
+ switch kubernetesType {
+ case qovery.CLOUDPROVIDERENUM_AWS:
+ valuesUrl = "https://raw.githubusercontent.com/Qovery/qovery-chart/main/charts/qovery/values-demo-aws.yaml"
+ case qovery.CLOUDPROVIDERENUM_GCP:
+ valuesUrl = "https://raw.githubusercontent.com/Qovery/qovery-chart/main/charts/qovery/values-demo-gcp.yaml"
+ case qovery.CLOUDPROVIDERENUM_SCW:
+ valuesUrl = "https://raw.githubusercontent.com/Qovery/qovery-chart/main/charts/qovery/values-demo-scaleway.yaml"
+ case qovery.CLOUDPROVIDERENUM_ON_PREMISE:
+ valuesUrl = "https://raw.githubusercontent.com/Qovery/qovery-chart/main/charts/qovery/values-demo-local.yaml"
+ }
+
+ res, err := http.Get(valuesUrl)
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(res.Body)
+
+ // Check server response
+ if res.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("bad status while downloading Qovery Helm Values file: %s", res.Status)
+ }
+
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ s := string(body)
+ return &s, nil
+}
+
+func mapCloudVendorToCloudProviderType(vendor qovery.CloudVendorEnum) qovery.CloudProviderEnum {
+ switch vendor {
+ case qovery.CLOUDVENDORENUM_AWS:
+ return qovery.CLOUDPROVIDERENUM_AWS
+ case qovery.CLOUDVENDORENUM_GCP:
+ return qovery.CLOUDPROVIDERENUM_GCP
+ case qovery.CLOUDVENDORENUM_SCW:
+ return qovery.CLOUDPROVIDERENUM_SCW
+ case qovery.CLOUDVENDORENUM_AZURE,
+ qovery.CLOUDVENDORENUM_OVH,
+ qovery.CLOUDVENDORENUM_DO,
+ qovery.CLOUDVENDORENUM_ORACLE,
+ qovery.CLOUDVENDORENUM_HETZNER,
+ qovery.CLOUDVENDORENUM_IBM,
+ qovery.CLOUDVENDORENUM_CIVO,
+ qovery.CLOUDVENDORENUM_ON_PREMISE:
+ return qovery.CLOUDPROVIDERENUM_ON_PREMISE
+ default:
+ return qovery.CLOUDPROVIDERENUM_ON_PREMISE
+ }
+}
diff --git a/pkg/cluster/selfmanaged/self_managed_cluster_service_test.go b/pkg/cluster/selfmanaged/self_managed_cluster_service_test.go
new file mode 100644
index 00000000..88d9d1bb
--- /dev/null
+++ b/pkg/cluster/selfmanaged/self_managed_cluster_service_test.go
@@ -0,0 +1,264 @@
+package selfmanaged
+
+import (
+ "fmt"
+ "github.com/jarcoal/httpmock"
+ "github.com/qovery/qovery-client-go"
+ "github.com/stretchr/testify/assert"
+ "net/http"
+ "os"
+ "testing"
+
+ mockCluster "github.com/qovery/qovery-cli/pkg/cluster"
+ mockContainerRegistry "github.com/qovery/qovery-cli/pkg/cluster/containerregistry"
+ mockCredentials "github.com/qovery/qovery-cli/pkg/cluster/credentials"
+ mockOrganization "github.com/qovery/qovery-cli/pkg/organization"
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func TestCreateCluster(t *testing.T) {
+ t.Run("Should create a new self managed cluster without creating credentials for AWS (same behavior for SCW & GCP)", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ mockCluster.MockCreateCluster(organization)
+
+ // given
+ var clusterService = mockCluster.ClusterServiceMock{
+ ResultListClusterRegions: func() (*qovery.ClusterRegionResponseList, error) {
+ return &qovery.ClusterRegionResponseList{Results: []qovery.ClusterRegion{{Name: "eu-west-3", CountryCode: "FR", Country: "France", City: "Paris"}}}, nil
+ },
+ }
+ var clusterCredentialsService = mockCredentials.ClusterCredentialsServiceMock{
+ ResultListClusterCredentials: func() (*qovery.ClusterCredentialsResponseList, error) {
+ return &qovery.ClusterCredentialsResponseList{Results: []qovery.ClusterCredentials{
+ {AwsStaticClusterCredentials: &qovery.AwsStaticClusterCredentials{Id: "id-credentials", Name: "AWS credentials"}},
+ }}, nil
+ },
+ ResultAskToCreateCredentials: func() (*qovery.ClusterCredentials, error) {
+ // Returns an error if the test asks to create credentials
+ return nil, fmt.Errorf("should never ask to create credentials")
+ },
+ }
+ var clusterContainerRegistryService = mockContainerRegistry.ContainerRegistryServiceMock{
+ ResultAskToEditClusterContainerRegistry: nil,
+ }
+
+ service := NewSelfManagedClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ &clusterService,
+ &clusterCredentialsService,
+ &clusterContainerRegistryService,
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Select the region where your cluster is installed": "eu-west-3",
+ "Which credentials do you want to use for the container registry ? A container registry is necessary to build and mirror the images deployed on your cluster.": "AWS credentials",
+ }),
+ )
+
+ // when
+ var cluster, err = service.Create(organization.Id, qovery.CLOUDVENDORENUM_AWS)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, cluster)
+ })
+ t.Run("Should create a new self managed cluster without creating credentials for On Premise cluster", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var organization = mockOrganization.CreateTestOrganization()
+ mockCluster.MockCreateCluster(organization)
+
+ // given
+ var clusterService = mockCluster.ClusterServiceMock{
+ ResultListClusterRegions: func() (*qovery.ClusterRegionResponseList, error) {
+ return nil, fmt.Errorf("should never ask for regions for on premise cluster creation")
+ },
+ }
+ var clusterCredentialsService = mockCredentials.ClusterCredentialsServiceMock{
+ ResultListClusterCredentials: func() (*qovery.ClusterCredentialsResponseList, error) {
+ return &qovery.ClusterCredentialsResponseList{Results: []qovery.ClusterCredentials{
+ {GenericClusterCredentials: &qovery.GenericClusterCredentials{Id: "id-credentials", Name: "AWS credentials"}},
+ }}, nil
+ },
+
+ ResultAskToCreateCredentials: func() (*qovery.ClusterCredentials, error) {
+ // Returns an error if the test asks to create credentials
+ return nil, fmt.Errorf("should never ask to create credentials")
+ },
+ }
+ var clusterContainerRegistryService = mockContainerRegistry.ContainerRegistryServiceMock{
+ ResultAskToEditClusterContainerRegistry: nil,
+ }
+
+ service := NewSelfManagedClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ &clusterService,
+ &clusterCredentialsService,
+ &clusterContainerRegistryService,
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{
+ "Which credentials do you want to use for the container registry ? A container registry is necessary to build and mirror the images deployed on your cluster.": "AWS credentials",
+ }),
+ )
+
+ // when
+ var cluster, err = service.Create(organization.Id, qovery.CLOUDVENDORENUM_ON_PREMISE)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, cluster)
+ })
+}
+
+func TestConfigureCluster(t *testing.T) {
+ t.Run("Should succeed to configure a self managed cluster for a On Premise cluster", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ var cluster = mockCluster.CreateTestCluster(mockOrganization.CreateTestOrganization())
+ cluster.SetCloudProvider(qovery.CLOUDVENDORENUM_ON_PREMISE)
+
+ // given
+ var clusterService = mockCluster.ClusterServiceMock{}
+ var clusterCredentialsService = mockCredentials.ClusterCredentialsServiceMock{}
+ var clusterContainerRegistryService = mockContainerRegistry.ContainerRegistryServiceMock{
+ ResultAskToEditClusterContainerRegistry: nil,
+ }
+
+ service := NewSelfManagedClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ &clusterService,
+ &clusterCredentialsService,
+ &clusterContainerRegistryService,
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ var err = service.Configure(cluster)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, cluster)
+ })
+}
+
+func TestGetInstallationHelmValues(t *testing.T) {
+ t.Run("Should get installation helm values cluster id", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks
+ organization := mockOrganization.CreateTestOrganization()
+ var cluster = mockCluster.CreateTestCluster(organization)
+ MockGetInstallationHelmValues(organization, cluster)
+
+ // given
+ service := NewSelfManagedClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ nil,
+ nil,
+ nil,
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ var content, err = service.GetInstallationHelmValues(organization.Id, cluster.Id)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, content)
+ })
+}
+
+func TestGetBaseHelmValuesContent(t *testing.T) {
+ t.Run("Should get installation helm values from a local base values file when provided", func(t *testing.T) {
+ baseValuesFile, err := os.CreateTemp(t.TempDir(), "values-*.yaml")
+ if err != nil {
+ t.Fatalf("create temp values file: %v", err)
+ }
+ defer func() {
+ _ = baseValuesFile.Close()
+ }()
+
+ expectedContent := "services:\n ingress:\n ingress-nginx:\n enabled: false\n"
+ if _, err := baseValuesFile.WriteString(expectedContent); err != nil {
+ t.Fatalf("write temp values file: %v", err)
+ }
+
+ service := NewSelfManagedClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ nil,
+ nil,
+ nil,
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ baseValuesFile.Name(),
+ )
+
+ content, err := service.GetBaseHelmValuesContent(qovery.CLOUDPROVIDERENUM_SCW)
+
+ assert.Nil(t, err)
+ assert.Equal(t, expectedContent, *content)
+ })
+
+ testCases := []struct {
+ Name string
+ CloudProviderType qovery.CloudProviderEnum
+ URL string
+ }{
+ {
+ Name: "AWS",
+ CloudProviderType: qovery.CLOUDPROVIDERENUM_AWS,
+ URL: "https://raw.githubusercontent.com/Qovery/qovery-chart/main/charts/qovery/values-demo-aws.yaml",
+ },
+ {
+ Name: "SCW",
+ CloudProviderType: qovery.CLOUDPROVIDERENUM_SCW,
+ URL: "https://raw.githubusercontent.com/Qovery/qovery-chart/main/charts/qovery/values-demo-scaleway.yaml",
+ },
+ {
+ Name: "GCP",
+ CloudProviderType: qovery.CLOUDPROVIDERENUM_GCP,
+ URL: "https://raw.githubusercontent.com/Qovery/qovery-chart/main/charts/qovery/values-demo-gcp.yaml",
+ },
+ {
+ Name: "ON_PREMISE",
+ CloudProviderType: qovery.CLOUDPROVIDERENUM_ON_PREMISE,
+ URL: "https://raw.githubusercontent.com/Qovery/qovery-chart/main/charts/qovery/values-demo-local.yaml",
+ },
+ }
+ for _, testCase := range testCases {
+ t.Run(fmt.Sprintf("Should get installation helm values cluster cloud provider %s", testCase.Name), func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ expectedContent := fmt.Sprintf("# %s values\n", testCase.Name)
+ httpmock.RegisterResponder("GET", testCase.URL,
+ func(request *http.Request) (*http.Response, error) {
+ return httpmock.NewStringResponse(200, expectedContent), nil
+ },
+ )
+
+ // given
+ service := NewSelfManagedClusterService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ nil,
+ nil,
+ nil,
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ var content, err = service.GetBaseHelmValuesContent(testCase.CloudProviderType)
+
+ // then
+ assert.Nil(t, err)
+ assert.NotNil(t, content)
+ assert.Equal(t, expectedContent, *content)
+ })
+ }
+}
diff --git a/pkg/delete_cluster.go b/pkg/delete_cluster.go
new file mode 100644
index 00000000..2dd40577
--- /dev/null
+++ b/pkg/delete_cluster.go
@@ -0,0 +1,76 @@
+package pkg
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func DeleteClusterById(clusterId string, dryRunDisabled bool) {
+
+ utils.DryRunPrint(dryRunDisabled)
+ if utils.Validate("delete") {
+ res := httpDelete(utils.GetAdminUrl()+"/cluster/"+clusterId, http.MethodDelete, dryRunDisabled)
+
+ if !dryRunDisabled {
+ fmt.Println("Cluster with id " + clusterId + " deletable.")
+ } else if !strings.Contains(res.Status, "200") {
+ result, _ := io.ReadAll(res.Body)
+ log.Errorf("Could not delete cluster with id %s : %s. %s", clusterId, res.Status, string(result))
+ } else {
+ fmt.Println("Cluster with id " + clusterId + " deleted.")
+ }
+ }
+}
+func DeleteClusterUnDeployedInError() {
+
+ if utils.Validate("delete") {
+ res := httpDelete(utils.GetAdminUrl()+"/cluster/deleteNotDeployedInErrorClusters", http.MethodPost, true)
+
+ if !strings.Contains(res.Status, "200") {
+ result, _ := io.ReadAll(res.Body)
+ log.Errorf("Could not delete all clusters undeployed and in error : %s. %s", res.Status, string(result))
+ } else {
+ result, _ := io.ReadAll(res.Body)
+ fmt.Println("Clusters deleted: " + string(result))
+ }
+ }
+}
+
+func DeleteOldClustersWithInvalidCredentials(ageInDay int, dryRunDisabled bool) {
+
+ if utils.Validate("delete") {
+
+ params := map[string]interface{}{
+ "last_update_in_days": ageInDay,
+ "dry_run": !dryRunDisabled,
+ }
+
+ requestBody, err := json.Marshal(params)
+ if err != nil {
+ log.Errorf("Could not create body for the request")
+ return
+ }
+
+ res := deleteWithBody(utils.GetAdminUrl()+"/cluster/deleteOldClustersWithInvalidCredentials", http.MethodPost, true, bytes.NewBuffer(requestBody))
+
+ if !strings.Contains(res.Status, "200") {
+ result, _ := io.ReadAll(res.Body)
+ log.Errorf("Could not delete all clusters with invalid credentials : %s. %s", res.Status, string(result))
+ } else {
+ result, _ := io.ReadAll(res.Body)
+ if dryRunDisabled {
+ fmt.Println("Clusters deleted: " + string(result))
+ } else {
+ fmt.Println("Clusters that will be deleted: " + string(result))
+ }
+ }
+ }
+}
diff --git a/pkg/delete_orga.go b/pkg/delete_orga.go
index a872b2a7..8b53add0 100644
--- a/pkg/delete_orga.go
+++ b/pkg/delete_orga.go
@@ -1,37 +1,146 @@
package pkg
import (
+ "bytes"
+ "encoding/json"
"fmt"
- "github.com/qovery/qovery-cli/utils"
- log "github.com/sirupsen/logrus"
- "io/ioutil"
+ "io"
"net/http"
"os"
- "strings"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/qovery/qovery-cli/utils"
)
-func DeleteOrganizationByClusterId(clusterId string, dryRunDisabled bool) {
- utils.CheckAdminUrl()
+type DeleteOrganizationsResponse struct {
+ Deleted []string `json:"deleted"`
+ Failed []DeleteOrganizationFailure `json:"failed"`
+}
+
+type DeleteOrganizationFailure struct {
+ OrganizationID string `json:"organization_id"`
+ Reason string `json:"reason"`
+}
+
+func DeleteOrganizations(organizationIds []string, allowFailedClusters bool, dryRunDisabled bool) {
+ utils.GetAdminUrl()
utils.DryRunPrint(dryRunDisabled)
- if utils.Validate("delete") {
- res := delete(utils.AdminUrl+"/organization?clusterId="+clusterId, http.MethodDelete, dryRunDisabled)
-
- if !dryRunDisabled {
- fmt.Println("Organization owning cluster" + clusterId + " deletable.")
- } else if !strings.Contains(res.Status, "200") {
- result, _ := ioutil.ReadAll(res.Body)
- log.Errorf("Could not delete organization owning cluster %s : %s. %s", clusterId, res.Status, string(result))
- } else {
- fmt.Println("Organization owning cluster" + clusterId + " deleted.")
+
+ if len(organizationIds) == 0 {
+ log.Error("No organization IDs provided")
+ os.Exit(1)
+ }
+
+ if !utils.Validate("delete") {
+ return
+ }
+
+ // Build URL with allowFailedClusters parameter
+ url := utils.GetAdminUrl() + "/organizations"
+ if allowFailedClusters {
+ url += "?allowFailedClusters=true"
+ }
+
+ // Prepare JSON body with organization IDs
+ body, err := json.Marshal(organizationIds)
+ if err != nil {
+ log.Fatalf("Failed to marshal organization IDs: %v", err)
+ }
+
+ if !dryRunDisabled {
+ fmt.Printf("Would delete %d organization(s) (allowFailedClusters=%t):\n", len(organizationIds), allowFailedClusters)
+ for _, id := range organizationIds {
+ fmt.Printf(" - %s\n", id)
}
+ return
}
+
+ // Make HTTP request
+ res := deleteWithBody(url, http.MethodDelete, true, bytes.NewReader(body))
+ if res == nil {
+ log.Error("Failed to execute delete request")
+ return
+ }
+ defer func() {
+ if err := res.Body.Close(); err != nil {
+ log.Warnf("Failed to close response body: %v", err)
+ }
+ }()
+
+ // Handle response
+ if res.StatusCode != http.StatusOK {
+ result, _ := io.ReadAll(res.Body)
+ log.Errorf("Failed to delete organizations (status %d): %s", res.StatusCode, string(result))
+ os.Exit(1)
+ }
+
+ // Parse response
+ responseBody, err := io.ReadAll(res.Body)
+ if err != nil {
+ log.Errorf("Failed to read response: %v", err)
+ os.Exit(1)
+ }
+
+ var response DeleteOrganizationsResponse
+ if err := json.Unmarshal(responseBody, &response); err != nil {
+ log.Errorf("Failed to parse response: %v", err)
+ os.Exit(1)
+ }
+
+ // Display results
+ displayDeletionResults(response, len(organizationIds))
}
-func delete(url string, method string, dryRunDisabled bool) *http.Response {
- authToken, tokenErr := utils.GetAccessToken()
- if tokenErr != nil {
- utils.PrintlnError(tokenErr)
+func displayDeletionResults(response DeleteOrganizationsResponse, totalRequested int) {
+ fmt.Println()
+ fmt.Println("====================================")
+ fmt.Println(" Organization Deletion Results")
+ fmt.Println("====================================")
+ fmt.Println()
+
+ successCount := len(response.Deleted)
+ failureCount := len(response.Failed)
+
+ fmt.Printf("Total requested: %d\n", totalRequested)
+ fmt.Printf("Successfully deleted: %d\n", successCount)
+ fmt.Printf("Failed to delete: %d\n", failureCount)
+ fmt.Println()
+
+ if successCount > 0 {
+ fmt.Println("â Successfully deleted organizations:")
+ for _, id := range response.Deleted {
+ fmt.Printf(" â %s\n", id)
+ }
+ fmt.Println()
+ }
+
+ if failureCount > 0 {
+ fmt.Println("â Failed to delete organizations:")
+ for _, failure := range response.Failed {
+ fmt.Printf(" â %s\n", failure.OrganizationID)
+ fmt.Printf(" Reason: %s\n", failure.Reason)
+ }
+ fmt.Println()
+ }
+
+ if failureCount > 0 {
+ fmt.Println("Some deletions failed. Check the reasons above.")
+ os.Exit(1)
+ } else {
+ fmt.Println("All organizations deleted successfully! â")
+ }
+}
+
+func httpDelete(url string, method string, dryRunDisabled bool) *http.Response {
+ return deleteWithBody(url, method, dryRunDisabled, nil)
+}
+
+func deleteWithBody(url string, method string, dryRunDisabled bool, body io.Reader) *http.Response {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
os.Exit(0)
}
@@ -39,12 +148,12 @@ func delete(url string, method string, dryRunDisabled bool) *http.Response {
return nil
}
- req, err := http.NewRequest(method, url, nil)
+ req, err := http.NewRequest(method, url, body)
if err != nil {
log.Fatal(err)
}
- req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(authToken)))
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
diff --git a/pkg/delete_project.go b/pkg/delete_project.go
new file mode 100644
index 00000000..96a4ab66
--- /dev/null
+++ b/pkg/delete_project.go
@@ -0,0 +1,28 @@
+package pkg
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func DeleteProjectById(projectId string, dryRunDisabled bool) {
+ utils.DryRunPrint(dryRunDisabled)
+ if utils.Validate("delete") {
+ res := httpDelete(utils.GetAdminUrl()+"/project/"+projectId, http.MethodDelete, dryRunDisabled)
+
+ if !dryRunDisabled {
+ fmt.Println("Project with id " + projectId + " deletable.")
+ } else if !strings.Contains(res.Status, "200") && !strings.Contains(res.Status, "204") {
+ result, _ := io.ReadAll(res.Body)
+ log.Errorf("Could not delete project with id %s : %s. %s", projectId, res.Status, string(result))
+ } else {
+ fmt.Println("Project with id " + projectId + " deleted.")
+ }
+ }
+}
diff --git a/pkg/deploy.go b/pkg/deploy.go
index 9f91c5aa..c83eb1b6 100644
--- a/pkg/deploy.go
+++ b/pkg/deploy.go
@@ -5,52 +5,17 @@ import (
"fmt"
"github.com/qovery/qovery-cli/utils"
log "github.com/sirupsen/logrus"
- "io/ioutil"
+ "io"
"net/http"
"os"
"strings"
+ "time"
)
-func DeployById(clusterId string, dryRunDisabled bool) {
- utils.CheckAdminUrl()
-
- utils.DryRunPrint(dryRunDisabled)
- if utils.Validate("deployment") {
- res := deploy(utils.AdminUrl+"/cluster/deploy/"+clusterId, http.MethodPost, dryRunDisabled)
-
- if !strings.Contains(res.Status, "200") {
- result, _ := ioutil.ReadAll(res.Body)
- log.Errorf("Could not deploy cluster : %s. %s", res.Status, string(result))
- } else if !dryRunDisabled {
- fmt.Println("Cluster " + clusterId + " deployable.")
- } else {
- fmt.Println("Cluster " + clusterId + " deploying.")
- }
- }
-}
-
-func DeployAll(dryRunDisabled bool) {
- utils.CheckAdminUrl()
-
- utils.DryRunPrint(dryRunDisabled)
- if utils.Validate("deployment") {
- res := deploy(utils.AdminUrl+"/cluster/deploy", http.MethodPost, dryRunDisabled)
-
- if !strings.Contains(res.Status, "200") {
- result, _ := ioutil.ReadAll(res.Body)
- log.Errorf("Could not deploy clusters : %s. %s", res.Status, string(result))
- } else if !dryRunDisabled {
- fmt.Println("Clusters deployable.")
- } else {
- fmt.Println("Clusters deploying.")
- }
- }
-}
-
-func deploy(url string, method string, dryRunDisabled bool) *http.Response {
- authToken, tokenErr := utils.GetAccessToken()
- if tokenErr != nil {
- utils.PrintlnError(tokenErr)
+func execAdminRequest(url string, method string, dryRunDisabled bool, queryParams map[string]string) *http.Response {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
os.Exit(0)
}
@@ -65,8 +30,13 @@ func deploy(url string, method string, dryRunDisabled bool) *http.Response {
log.Fatal(err)
}
- req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(authToken)))
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
req.Header.Set("Content-Type", "application/json")
+ query := req.URL.Query()
+ for key, value := range queryParams {
+ query.Add(key, value)
+ }
+ req.URL.RawQuery = query.Encode()
res, err := http.DefaultClient.Do(req)
if err != nil {
@@ -75,3 +45,23 @@ func deploy(url string, method string, dryRunDisabled bool) *http.Response {
return res
}
+
+func ForceFailedDeploymentsToInternalErrorStatus(safeguardDuration time.Duration) {
+ if !utils.Validate("force deployment status") {
+ return
+ }
+ nbMinutes := int(safeguardDuration.Minutes())
+ if nbMinutes < 5 {
+ log.Errorf("Could not force the deployments if safeguard is lower than 5minutes. Got %d", nbMinutes)
+ }
+
+ durationIso8601 := fmt.Sprintf("PT%dM", nbMinutes)
+ queryParams := map[string]string{"safeguardDuration": durationIso8601}
+ res := execAdminRequest(utils.GetAdminUrl()+"/deployment/forceFailedDeploymentsToInternalErrorStatus", http.MethodPost, true, queryParams)
+ if !strings.Contains(res.Status, "200") {
+ result, _ := io.ReadAll(res.Body)
+ log.Errorf("Could not force the deployments status : %s. %s", res.Status, string(result))
+ } else {
+ fmt.Println("INTERNAL_ERROR status forced")
+ }
+}
diff --git a/pkg/download_s3_archive.go b/pkg/download_s3_archive.go
new file mode 100644
index 00000000..7782db9b
--- /dev/null
+++ b/pkg/download_s3_archive.go
@@ -0,0 +1,124 @@
+package pkg
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ log "github.com/sirupsen/logrus"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
+var uuidWithTimestampRegex = regexp.MustCompile(`^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-\d+$`)
+
+type ArchiveTagsResponse struct {
+ Key string
+ Value string
+}
+
+type ArchiveResponse struct {
+ Archive string
+ Tags []ArchiveTagsResponse
+}
+
+func DownloadS3Archive(executionId string, directory string) {
+ if matches := uuidWithTimestampRegex.FindStringSubmatch(executionId); matches != nil {
+ log.Warnf("Execution id '%s' contains a timestamp suffix, stripping it automatically", executionId)
+ executionId = matches[1]
+ } else if !uuidRegex.MatchString(executionId) {
+ log.Errorf("Invalid execution id format: '%s'. Expected a UUID (e.g. xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)", executionId)
+ return
+ }
+
+ fileName := executionId + ".tgz"
+ res := download(utils.GetAdminUrl()+"/getS3ArchiveObject", fileName)
+
+ if !strings.Contains(res.Status, "200") {
+ result, _ := io.ReadAll(res.Body)
+ log.Errorf("Could not download archive for key %s: %s. %s", fileName, res.Status, string(result))
+ return
+ }
+
+ archiveResponse := ArchiveResponse{}
+ err := json.NewDecoder(res.Body).Decode(&archiveResponse)
+ if err != nil {
+ log.Errorf("Could not decode JSON: %v", err)
+ return
+ }
+
+ organizationId := findOrganizationInTag(archiveResponse.Tags)
+ if organizationId == nil {
+ log.Warning("Could not find organization tags")
+ }
+
+ path := filepath.Join(directory, fileName)
+ location := path
+ if !filepath.IsAbs(path) {
+ location = "./" + path
+ }
+
+ utils.PrintlnInfo(fmt.Sprintf("Would you like to write the file in '%s' ?", location))
+ // check if it is the expected org
+ if !utils.Validate("") {
+ return
+ }
+
+ decodedBytes, err := base64.StdEncoding.DecodeString(archiveResponse.Archive)
+ if err != nil {
+ log.Fatalf("Failed to decode base64 archive: %v", err)
+ }
+
+ err = writeFile(path, decodedBytes)
+ if err != nil {
+ log.Fatalf("Failed to write archive to file: %v", err)
+ } else {
+ utils.PrintlnInfo(fmt.Sprintf("File '%s' has been written", location))
+ }
+}
+
+func findOrganizationInTag(tags []ArchiveTagsResponse) *string {
+ for _, tag := range tags {
+ if tag.Key == "OrganizationLongId" {
+ return &tag.Value
+ }
+ }
+ return nil
+}
+
+func download(url string, executionId string) *http.Response {
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ content := fmt.Sprintf(`{ "key": "%s" }`, executionId)
+ body := bytes.NewBuffer([]byte(content))
+
+ req, err := http.NewRequest(http.MethodGet, url, body)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ return res
+}
+
+func writeFile(path string, data []byte) error {
+ return os.WriteFile(path, data, 0644)
+}
diff --git a/pkg/enterpriseconnection/enterprise_connection_service.go b/pkg/enterpriseconnection/enterprise_connection_service.go
new file mode 100644
index 00000000..5fe4b374
--- /dev/null
+++ b/pkg/enterpriseconnection/enterprise_connection_service.go
@@ -0,0 +1,267 @@
+package enterpriseconnection
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-cli/pkg/usercontext"
+ "github.com/qovery/qovery-cli/utils"
+ "github.com/qovery/qovery-client-go"
+)
+
+// EnterpriseConnectionService provides centralized operations for enterprise connections
+type EnterpriseConnectionService struct {
+ client *qovery.APIClient
+ organizationId string
+ availableRolesByName map[string]string // roleName (lowercase) -> roleId
+ customRoleNamesById map[string]string // roleId -> roleName
+ customRoleIdsByName map[string]string // roleName (lowercase) -> roleId
+}
+
+// NewEnterpriseConnectionService creates a new service instance with authentication
+func NewEnterpriseConnectionService(organizationName string) (*EnterpriseConnectionService, error) {
+ // Get access token and client
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ client := utils.GetQoveryClient(tokenType, token)
+
+ organizationId, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
+ if err != nil {
+ return nil, err
+ }
+
+ service := &EnterpriseConnectionService{
+ client: client,
+ organizationId: organizationId,
+ }
+
+ // Initialize role mappings
+ if err := service.initializeRoleMappings(); err != nil {
+ return nil, err
+ }
+
+ return service, nil
+}
+
+// initializeRoleMappings loads and caches role information
+func (s *EnterpriseConnectionService) initializeRoleMappings() error {
+ // Fetch available roles
+ availableRoles, _, err := s.client.OrganizationMainCallsAPI.ListOrganizationAvailableRoles(context.Background(), s.organizationId).Execute()
+ if err != nil {
+ return err
+ }
+
+ s.availableRolesByName = make(map[string]string)
+ for _, role := range availableRoles.Results {
+ s.availableRolesByName[strings.ToLower(role.Name)] = role.Id
+ }
+
+ // Fetch custom roles
+ customRoles, _, err := s.client.OrganizationCustomRoleAPI.ListOrganizationCustomRoles(context.Background(), s.organizationId).Execute()
+ if err != nil {
+ return err
+ }
+
+ s.customRoleNamesById = make(map[string]string)
+ s.customRoleIdsByName = make(map[string]string)
+ for _, role := range customRoles.Results {
+ s.customRoleNamesById[*role.Id] = *role.Name
+ s.customRoleIdsByName[strings.ToLower(*role.Name)] = *role.Id
+ }
+
+ return nil
+}
+
+func (s *EnterpriseConnectionService) ListEnterpriseConnections(connectionName string) ([]qovery.EnterpriseConnectionDto, error) {
+ if connectionName == "" {
+ connections, _, err := s.client.OrganizationEnterpriseConnectionAPI.ListOrganizationEnterpriseConnections(
+ context.Background(),
+ s.organizationId,
+ ).Execute()
+ utils.CheckError(err)
+ return connections.GetResults(), nil
+ }
+
+ connection, err := s.GetEnterpriseConnection(connectionName)
+ utils.CheckError(err)
+ return []qovery.EnterpriseConnectionDto{*connection}, err
+}
+
+// GetEnterpriseConnection retrieves an enterprise connection by name
+func (s *EnterpriseConnectionService) GetEnterpriseConnection(connectionName string) (*qovery.EnterpriseConnectionDto, error) {
+ connection, _, err := s.client.OrganizationEnterpriseConnectionAPI.GetOrganizationEnterpriseConnection(
+ context.Background(),
+ s.organizationId,
+ connectionName,
+ ).Execute()
+
+ return connection, err
+}
+
+// UpdateEnterpriseConnection updates an enterprise connection
+func (s *EnterpriseConnectionService) UpdateEnterpriseConnection(connectionName string, dto qovery.EnterpriseConnectionDto) (*qovery.EnterpriseConnectionDto, error) {
+ connection, _, err := s.client.OrganizationEnterpriseConnectionAPI.UpdateOrganizationEnterpriseConnection(
+ context.Background(),
+ s.organizationId,
+ connectionName,
+ ).EnterpriseConnectionDto(dto).Execute()
+
+ return connection, err
+}
+
+// ResolveRoleDisplayName converts role UUID to display name if applicable
+func (s *EnterpriseConnectionService) ResolveRoleDisplayName(roleIdOrName string) string {
+ if err := uuid.Validate(roleIdOrName); err == nil {
+ // It's a UUID, try to find the display name
+ if roleName, exists := s.customRoleNamesById[roleIdOrName]; exists {
+ return roleName
+ }
+ }
+ // Not a UUID or UUID not found, return as-is
+ return roleIdOrName
+}
+
+// ResolveProvidedRoleNameOrCustomRoleId resolves a role name to its ID (handles both regular and custom roles)
+func (s *EnterpriseConnectionService) ResolveProvidedRoleNameOrCustomRoleId(roleName string) (string, error) {
+ // Check if it's a custom role
+ lowerRoleName := strings.ToLower(roleName)
+
+ // Return custom role id if exists
+ if value, exists := s.customRoleIdsByName[lowerRoleName]; exists {
+ return value, nil
+ }
+
+ // Return provided role name
+ if _, exists := s.availableRolesByName[lowerRoleName]; exists {
+ return lowerRoleName, nil
+ }
+
+ return "", fmt.Errorf("role '%s' not found", roleName)
+}
+
+// ValidateRole checks if a role exists in the organization
+func (s *EnterpriseConnectionService) ValidateRole(roleName string) error {
+ _, err := s.ResolveProvidedRoleNameOrCustomRoleId(roleName)
+ return err
+}
+
+// DisplayGroupMappingsTable formats and displays group mappings in a table
+//func (s *EnterpriseConnectionService) DisplayGroupMappingsTable(groupMappings map[string][]string) error {
+// var data [][]string
+//
+// for roleIdOrName, idpGroups := range groupMappings {
+// idpGroupsStr := strings.Join(idpGroups, ", ")
+// displayName := s.ResolveRoleDisplayName(roleIdOrName)
+// data = append(data, []string{displayName, idpGroupsStr})
+// }
+//
+// // Sort data by role name (first column)
+// sort.Slice(data, func(i, j int) bool {
+// return data[i][0] < data[j][0]
+// })
+//
+// return utils.PrintTable([]string{"Qovery Role", "Your IDPs roles"}, data)
+//}
+
+//// DisplayEnterpriseConnection displays the complete enterprise connection information
+//func (s *EnterpriseConnectionService) DisplayEnterpriseConnection(connection *qovery.EnterpriseConnectionDto) error {
+// // Display connection settings in table format
+// defaultRoleDisplay := s.ResolveRoleDisplayName(connection.DefaultRole)
+// settingsData := [][]string{
+// {defaultRoleDisplay, fmt.Sprintf("%t", connection.EnforceGroupSync)},
+// }
+//
+// utils.Println(fmt.Sprintf("Connection name: %s", connection.ConnectionName))
+// err := utils.PrintTable([]string{"Default Role", "Enforce Sync Group"}, settingsData)
+// if err != nil {
+// return err
+// }
+//
+// utils.Println("Group Mappings:")
+// utils.Println("==============")
+// return s.DisplayGroupMappingsTable(connection.GroupMappings)
+//}
+//
+
+// ParseIdpGroupNames parses comma-separated IDP group names
+func ParseIdpGroupNames(idpGroupNames string) []string {
+ if idpGroupNames == "" {
+ return []string{}
+ }
+
+ parts := strings.Split(idpGroupNames, ",")
+ var result []string
+ for _, part := range parts {
+ trimmed := strings.TrimSpace(part)
+ if trimmed != "" {
+ result = append(result, trimmed)
+ }
+ }
+ return result
+}
+
+// DisplayEnterpriseConnection displays the complete enterprise connection information
+func (s *EnterpriseConnectionService) DisplayEnterpriseConnection(connection *qovery.EnterpriseConnectionDto) error {
+ pterm.DefaultSection.Printfln("Connection Name: %s", connection.ConnectionName)
+ // Display connection settings in table format
+ defaultRoleDisplay := s.ResolveRoleDisplayName(connection.DefaultRole)
+
+ // Style the boolean value
+ enforceSyncDisplay := pterm.FgRed.Sprintf("â false")
+ if connection.EnforceGroupSync {
+ enforceSyncDisplay = pterm.FgGreen.Sprintf("â true")
+ }
+
+ settingsData := [][]string{
+ {defaultRoleDisplay, enforceSyncDisplay},
+ }
+
+ // Print settings section
+ pterm.DefaultSection.WithTopPadding(0).WithBottomPadding(0).Println("Connection Settings")
+ err := utils.PrintTable([]string{"Default Role", "Enforce Sync Group"}, settingsData)
+ if err != nil {
+ return err
+ }
+
+ // Print group mappings section
+ pterm.DefaultSection.WithTopPadding(0).WithBottomPadding(0).Println("Group Mappings")
+ return s.DisplayGroupMappingsTable(connection.GroupMappings)
+}
+
+func (s *EnterpriseConnectionService) DisplayGroupMappingsTable(groupMappings map[string][]string) error {
+ if len(groupMappings) == 0 {
+ pterm.Info.Println("No group mappings configured")
+ return nil
+ }
+
+ var data [][]string
+
+ for roleIdOrName, idpGroups := range groupMappings {
+ displayName := s.ResolveRoleDisplayName(roleIdOrName)
+ idpGroupsStr := strings.Join(idpGroups, pterm.Gray(" ; "))
+ data = append(data, []string{displayName, idpGroupsStr})
+ }
+
+ // Sort data by role name (first column)
+ sort.Slice(data, func(i, j int) bool {
+ return data[i][0] < data[j][0]
+ })
+
+ return utils.PrintTable([]string{"Qovery Role", "Your IDP Groups"}, data)
+}
+
+// CreateConnectionUpdateDto creates a DTO for updating enterprise connection
+func CreateConnectionUpdateDto(defaultRole string, enforceGroupSync bool, groupMappings map[string][]string) qovery.EnterpriseConnectionDto {
+ return qovery.EnterpriseConnectionDto{
+ DefaultRole: defaultRole,
+ EnforceGroupSync: enforceGroupSync,
+ GroupMappings: groupMappings,
+ }
+}
diff --git a/pkg/filewriter/file_writer_mock.go b/pkg/filewriter/file_writer_mock.go
new file mode 100644
index 00000000..8d1e878f
--- /dev/null
+++ b/pkg/filewriter/file_writer_mock.go
@@ -0,0 +1,17 @@
+//go:build testing
+
+package filewriter
+
+import (
+ "io/fs"
+)
+
+type FileWriterServiceMock struct {
+ FileContentWritten string
+}
+
+func (service *FileWriterServiceMock) WriteFile(name string, data []byte, perm fs.FileMode) error {
+ service.FileContentWritten = string(data)
+
+ return nil
+}
diff --git a/pkg/filewriter/file_writer_service.go b/pkg/filewriter/file_writer_service.go
new file mode 100644
index 00000000..b1746994
--- /dev/null
+++ b/pkg/filewriter/file_writer_service.go
@@ -0,0 +1,20 @@
+package filewriter
+
+import (
+ "io/fs"
+ "os"
+)
+
+type FileWriterService interface {
+ WriteFile(name string, data []byte, perm fs.FileMode) error
+}
+
+type FileWriterServiceImpl struct{}
+
+func NewFileWriterService() *FileWriterServiceImpl {
+ return &FileWriterServiceImpl{}
+}
+
+func (service *FileWriterServiceImpl) WriteFile(name string, data []byte, perm fs.FileMode) error {
+ return os.WriteFile(name, data, perm)
+}
diff --git a/pkg/lock.go b/pkg/lock.go
index b32029ba..91b972d0 100644
--- a/pkg/lock.go
+++ b/pkg/lock.go
@@ -1,13 +1,12 @@
package pkg
import (
- "bytes"
"encoding/json"
"fmt"
- "io/ioutil"
+ "io"
"net/http"
"os"
- "strings"
+ "strconv"
"text/tabwriter"
"time"
@@ -16,12 +15,12 @@ import (
)
func LockedClusters() {
- utils.CheckAdminUrl()
+ utils.GetAdminUrl()
res := listLockedClusters()
if res.StatusCode != http.StatusOK {
- result, _ := ioutil.ReadAll(res.Body)
+ result, _ := io.ReadAll(res.Body)
log.Errorf("Could not list locked clusters : %s. %s", res.Status, string(result))
return
}
@@ -33,10 +32,11 @@ func LockedClusters() {
OwnerName string `json:"owner_name"`
Reason string `json:"reason"`
LockedAt time.Time `json:"locked_at"`
+ TtlInDays *int `json:"ttl_in_days"`
} `json:"results"`
}{}
- body, err := ioutil.ReadAll(res.Body)
+ body, err := io.ReadAll(res.Body)
if err != nil {
log.Fatal(err)
}
@@ -45,93 +45,38 @@ func LockedClusters() {
}
w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
- format := "%s\t | %s\t | %s\t | %s\t | %s\n"
- fmt.Fprintf(w, format, "", "cluster_id", "locked_at", "locked_by", "reason")
- for idx, lock := range resp.Results {
- fmt.Fprintf(w, format, fmt.Sprintf("%d", idx+1), lock.ClusterId, lock.LockedAt.Format(time.RFC1123), lock.OwnerName, lock.Reason)
- }
- w.Flush()
-}
-
-func LockById(clusterId string, reason string) {
- utils.CheckAdminUrl()
-
- if reason == "" {
- log.Errorf("Lock reason is required")
- return
+ format := "%s\t | %s\t | %s\t | %s\t | %s\t | %s\n"
+ if _, err := fmt.Fprintf(w, format, "", "cluster_id", "locked_at", "locked_by", "reason", "ttl_in_days"); err != nil {
+ log.Fatal(err)
}
-
- if utils.Validate("lock") {
- res := updateLockById(clusterId, reason, http.MethodPost)
-
- if res.StatusCode != http.StatusOK {
- result, _ := ioutil.ReadAll(res.Body)
- log.Errorf("Could not lock cluster : %s. %s", res.Status, string(result))
- } else {
- fmt.Println("Cluster locked.")
+ for idx, lock := range resp.Results {
+ ttlInDay := "infinite"
+ if lock.TtlInDays != nil {
+ ttlInDay = strconv.Itoa(*lock.TtlInDays)
}
- }
-}
-
-func UnockById(clusterId string) {
- utils.CheckAdminUrl()
- if utils.Validate("unlock") {
- res := updateLockById(clusterId, "", http.MethodDelete)
-
- if res.StatusCode != http.StatusOK {
- result, _ := ioutil.ReadAll(res.Body)
- log.Errorf("Could not unlock cluster : %s. %s", res.Status, string(result))
- } else {
- fmt.Println("Cluster unlocked.")
+ if _, err := fmt.Fprintf(w, format, fmt.Sprintf("%d", idx+1), lock.ClusterId, lock.LockedAt.Format(time.RFC1123), lock.OwnerName, lock.Reason, ttlInDay); err != nil {
+ log.Fatal(err)
}
}
-}
-
-func listLockedClusters() *http.Response {
- authToken, tokenErr := utils.GetAccessToken()
- if tokenErr != nil {
- utils.PrintlnError(tokenErr)
- os.Exit(0)
- }
-
- url := fmt.Sprintf("%s/cluster/lock", utils.AdminUrl)
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- log.Fatal(err)
- }
- req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(authToken)))
- req.Header.Set("Content-Type", "application/json")
-
- res, err := http.DefaultClient.Do(req)
- if err != nil {
+ if err := w.Flush(); err != nil {
log.Fatal(err)
}
- return res
}
-func updateLockById(clusterId string, reason string, method string) *http.Response {
- authToken, tokenErr := utils.GetAccessToken()
- if tokenErr != nil {
- utils.PrintlnError(tokenErr)
- os.Exit(0)
- }
-
- payload := map[string]string{}
- if method == http.MethodPost {
- payload["reason"] = reason
- }
- body, err := json.Marshal(payload)
+func listLockedClusters() *http.Response {
+ tokenType, token, err := utils.GetAccessToken(false)
if err != nil {
- log.Fatal(err)
+ utils.PrintlnError(err)
+ os.Exit(0)
}
- url := fmt.Sprintf("%s/cluster/lock/%s", utils.AdminUrl, clusterId)
- req, err := http.NewRequest(method, url, bytes.NewBuffer(body))
+ url := fmt.Sprintf("%s/cluster/lock", utils.GetAdminUrl())
+ req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
log.Fatal(err)
}
- req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(authToken)))
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
diff --git a/pkg/log.go b/pkg/log.go
new file mode 100644
index 00000000..ec5b6c66
--- /dev/null
+++ b/pkg/log.go
@@ -0,0 +1,110 @@
+package pkg
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/gorilla/websocket"
+ "github.com/qovery/qovery-cli/utils"
+ log "github.com/sirupsen/logrus"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+type LogRequest struct {
+ ServiceID utils.Id
+ EnvironmentID utils.Id
+ ProjectID utils.Id
+ OrganizationID utils.Id
+ ClusterID utils.Id
+ RawFormat bool
+}
+
+type LogMessage struct {
+ CreatedAt Timestamp `json:"created_at"`
+ Message string `json:"message"`
+ Version string `json:"version"`
+ PodName string `json:"pod_name"`
+}
+
+func ExecLog(req *LogRequest) {
+ wsConn, err := createLogWebsocket(req)
+ if err != nil {
+ log.Fatal("error while creating websocket connection", err)
+ }
+ defer func() {
+ if err := wsConn.Close(); err != nil {
+ log.Fatal("error while closing websocket connection", err)
+ }
+ }()
+
+ var logMessage LogMessage
+ for {
+ _, msg, err := wsConn.ReadMessage()
+ if err != nil {
+ if e, ok := err.(*websocket.CloseError); ok {
+ log.Error("connection closed by server: ", e)
+ return
+ }
+ log.Error("error while reading on websocket:", err)
+ return
+ }
+
+ if req.RawFormat {
+ fmt.Printf("%s\n", msg)
+ } else {
+ err = json.Unmarshal(msg, &logMessage)
+ if err != nil {
+ log.Fatal("%", err)
+ }
+ fmt.Printf("| %s | %s | %s\n", logMessage.CreatedAt.Format("2006-01-02 15:04:05.000"), logMessage.PodName, logMessage.Message)
+ }
+ }
+}
+
+func createLogWebsocket(req *LogRequest) (*websocket.Conn, error) {
+ wsURL, err := url.Parse(fmt.Sprintf(
+ "%s/service/logs?service=%s&cluster=%s&environment=%s&organization=%s&project=%s",
+ utils.WebsocketUrl(),
+ req.ServiceID,
+ req.ClusterID,
+ req.EnvironmentID,
+ req.OrganizationID,
+ req.ProjectID,
+ ))
+ if err != nil {
+ return nil, err
+ }
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ headers := http.Header{"Authorization": {utils.GetAuthorizationHeaderValue(tokenType, token)}}
+ wsConn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), headers)
+ if err != nil {
+ return nil, err
+ }
+ return wsConn, nil
+}
+
+type Timestamp struct {
+ time.Time
+}
+
+// UnmarshalJSON decodes an int64 timestamp into a time.Time object
+func (p *Timestamp) UnmarshalJSON(bytes []byte) error {
+ // 1. Decode the bytes into an int64
+ var raw int64
+ err := json.Unmarshal(bytes, &raw)
+
+ if err != nil {
+ fmt.Printf("error decoding timestamp: %s\n", err)
+ return err
+ }
+
+ // 2. Parse the unix timestamp
+ p.Time = time.UnixMilli(raw)
+ return nil
+}
diff --git a/pkg/organization/organization_mock.go b/pkg/organization/organization_mock.go
new file mode 100644
index 00000000..a0d754be
--- /dev/null
+++ b/pkg/organization/organization_mock.go
@@ -0,0 +1,50 @@
+//go:build testing
+
+package organization
+
+import (
+ "github.com/google/uuid"
+ "github.com/jarcoal/httpmock"
+ "github.com/qovery/qovery-client-go"
+ "net/http"
+ "time"
+)
+
+var testOrganizationId = "00000000-0000-0000-0000-000000000000"
+
+// CreateTestOrganization Used to create one single organization with predefined values
+func CreateTestOrganization() *qovery.Organization {
+ return qovery.NewOrganization(testOrganizationId, time.Now(), "TestOrganization", qovery.PLANENUM_FREE)
+}
+
+// CreateRandomTestOrganization Used to create a few organizations with random values for ID and name
+func CreateRandomTestOrganization() *qovery.Organization {
+ return qovery.NewOrganization(uuid.NewString(), time.Now(), uuid.NewString(), qovery.PLANENUM_FREE)
+}
+
+func MockListOrganizationsOk(organizations []qovery.Organization) {
+ var listOrganizationsResponse = qovery.OrganizationResponseList{Results: organizations}
+ httpmock.RegisterResponder("GET", "https://api.qovery.com/organization",
+ func(req *http.Request) (*http.Response, error) {
+ resp, err := httpmock.NewJsonResponse(200, listOrganizationsResponse)
+ if err != nil {
+ return httpmock.NewStringResponse(500, ""), nil
+ }
+ return resp, nil
+ })
+}
+
+func MockListOrganizationsBadRequest() {
+ httpmock.RegisterResponder("GET", "https://api.qovery.com/organization",
+ func(req *http.Request) (*http.Response, error) {
+ return httpmock.NewStringResponse(400, "Bad Request"), nil
+ })
+}
+
+type OrganizationServiceMock struct {
+ ResultAskUserToSelectOrganization func() (*OrganizationDto, error)
+}
+
+func (mock *OrganizationServiceMock) AskUserToSelectOrganization() (*OrganizationDto, error) {
+ return mock.ResultAskUserToSelectOrganization()
+}
diff --git a/pkg/organization/organization_service.go b/pkg/organization/organization_service.go
new file mode 100644
index 00000000..e66c8400
--- /dev/null
+++ b/pkg/organization/organization_service.go
@@ -0,0 +1,77 @@
+package organization
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+)
+
+type OrganizationDto struct {
+ ID string
+ Name string
+}
+
+type OrganizationService interface {
+ AskUserToSelectOrganization() (*OrganizationDto, error)
+}
+
+type OrganizationServiceImpl struct {
+ client *qovery.APIClient
+ promptUiFactory promptuifactory.PromptUiFactory
+}
+
+func NewOrganizationService(client *qovery.APIClient, promptUiFactory promptuifactory.PromptUiFactory) *OrganizationServiceImpl {
+ return &OrganizationServiceImpl{
+ client,
+ promptUiFactory,
+ }
+}
+
+func (service *OrganizationServiceImpl) AskUserToSelectOrganization() (*OrganizationDto, error) {
+ organizations, res, err := service.client.OrganizationMainCallsAPI.ListOrganization(context.Background()).Execute()
+ if err != nil || res.StatusCode >= 400 {
+ return nil, fmt.Errorf("error when listing organizations: %s (response status = %s)", err, res.Status)
+ }
+
+ var organizationNames []string
+ var orgs = make(map[string]string)
+
+ for _, org := range organizations.GetResults() {
+ organizationNames = append(organizationNames, org.Name)
+ orgs[org.Name] = org.Id
+ }
+
+ if len(organizationNames) < 1 {
+ return nil, errors.New("no organization found")
+ }
+
+ if len(organizationNames) == 1 {
+ return &OrganizationDto{
+ ID: orgs[organizationNames[0]],
+ Name: organizationNames[0],
+ }, nil
+ }
+
+ fmt.Println("Organization:")
+ _, selectedOrganization, err := service.promptUiFactory.RunSelectWithSizeAndSearcher(
+ "Organization",
+ organizationNames,
+ 30,
+ func(input string, index int) bool {
+ return strings.Contains(strings.ToLower(organizationNames[index]), strings.ToLower(input))
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return &OrganizationDto{
+ ID: orgs[selectedOrganization],
+ Name: selectedOrganization,
+ }, nil
+}
diff --git a/pkg/organization/organization_service_test.go b/pkg/organization/organization_service_test.go
new file mode 100644
index 00000000..5e86c0ff
--- /dev/null
+++ b/pkg/organization/organization_service_test.go
@@ -0,0 +1,129 @@
+package organization
+
+import (
+ "testing"
+
+ "github.com/jarcoal/httpmock"
+ "github.com/qovery/qovery-client-go"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/qovery/qovery-cli/pkg/promptuifactory"
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func TestAskUserToSelectOrganization(t *testing.T) {
+ t.Run("Should list organizations and select the correct one", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks part
+ var organization1 = CreateRandomTestOrganization()
+ var organization2 = CreateRandomTestOrganization()
+ MockListOrganizationsOk([]qovery.Organization{*organization1, *organization2})
+
+ // given
+ // mock promptui organization to be the organization2 name
+ var promptUiExpectedValueByLabel = map[string]string{
+ "Organization": organization2.Name,
+ }
+ var organizationService = NewOrganizationService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, promptUiExpectedValueByLabel),
+ )
+
+ // when
+ var selectedOrganization, err = organizationService.AskUserToSelectOrganization()
+
+ // then
+ assert.Nil(t, err)
+ assert.Equal(t, selectedOrganization.ID, organization2.Id)
+ })
+ t.Run("Should select the only organization present when necessary", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks part
+ var organization = CreateTestOrganization()
+ MockListOrganizationsOk([]qovery.Organization{*organization})
+
+ // given
+ var organizationService = NewOrganizationService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ selectedOrganization, err := organizationService.AskUserToSelectOrganization()
+
+ // then
+ assert.Nil(t, err)
+ assert.Equal(t, selectedOrganization.ID, organization.Id)
+ })
+ t.Run("Should fail if response returns bad request", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks part
+ MockListOrganizationsBadRequest()
+
+ // given
+ var organizationService = NewOrganizationService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ _, err := organizationService.AskUserToSelectOrganization()
+
+ // then
+ assert.NotNil(t, err)
+ assert.Equal(t, err.Error(), "error when listing organizations: 400 Bad Request (response status = 400 Bad Request)")
+ })
+ t.Run("Should fail if no organization found", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks part
+ MockListOrganizationsOk([]qovery.Organization{})
+
+ // given
+ var organizationService = NewOrganizationService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{}),
+ )
+
+ // when
+ _, err := organizationService.AskUserToSelectOrganization()
+
+ // then
+ assert.NotNil(t, err)
+ assert.Equal(t, err.Error(), "no organization found")
+ })
+ t.Run("Should fail if prompt to select organization fails", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // mocks part
+ var organization1 = CreateRandomTestOrganization()
+ var organization2 = CreateRandomTestOrganization()
+ MockListOrganizationsOk([]qovery.Organization{*organization1, *organization2})
+
+ // given
+ var organizationService = NewOrganizationService(
+ utils.GetQoveryClient("Fake token type", "Fake token"),
+ promptuifactory.NewPromptUiFactoryMock(
+ map[string]bool{
+ "Organization": true,
+ },
+ map[string]string{},
+ ),
+ )
+
+ // when
+ _, err := organizationService.AskUserToSelectOrganization()
+
+ // then
+ assert.NotNil(t, err)
+ assert.Equal(t, err.Error(), "error for select 'Organization'")
+ })
+}
diff --git a/pkg/port-forward.go b/pkg/port-forward.go
new file mode 100644
index 00000000..df6fb39e
--- /dev/null
+++ b/pkg/port-forward.go
@@ -0,0 +1,150 @@
+package pkg
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "regexp"
+
+ "github.com/appscode/go-querystring/query"
+ "github.com/gorilla/websocket"
+ "github.com/qovery/qovery-cli/utils"
+ log "github.com/sirupsen/logrus"
+)
+
+type PortForwardRequest struct {
+ ServiceID utils.Id `url:"service"`
+ EnvironmentID utils.Id `url:"environment"`
+ ProjectID utils.Id `url:"project"`
+ OrganizationID utils.Id `url:"organization"`
+ ClusterID utils.Id `url:"cluster"`
+ PodName string `url:"pod_name,omitempty"`
+ ServiceType string `url:"service_type"`
+ Port uint16 `url:"port"`
+ LocalPort uint16
+}
+
+type WebsocketPortForward struct {
+ ws *websocket.Conn
+}
+
+func (w WebsocketPortForward) Write(p []byte) (n int, err error) {
+ err = w.ws.WriteMessage(websocket.BinaryMessage, p)
+
+ return len(p), err
+}
+func (w WebsocketPortForward) Read(p []byte) (n int, err error) {
+ for {
+ msgType, msg, err := w.ws.ReadMessage()
+ if err != nil {
+ return 0, err
+ }
+
+ if msgType == websocket.CloseMessage {
+ return 0, io.EOF
+ }
+
+ if msgType != websocket.BinaryMessage {
+ continue
+ }
+
+ return copy(p, msg), err
+ }
+}
+
+func mkWebsocketConn(req *PortForwardRequest) (*WebsocketPortForward, error) {
+ command, err := query.Values(req)
+ if err != nil {
+ return nil, err
+ }
+
+ wsURL, err := url.Parse(fmt.Sprintf("%s/shell/portforward", utils.WebsocketUrl()))
+ if err != nil {
+ return nil, err
+ }
+ pattern := regexp.MustCompile("%5B([0-9]+)%5D=")
+ wsURL.RawQuery = pattern.ReplaceAllString(command.Encode(), "[${1}]=")
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ headers := http.Header{"Authorization": {utils.GetAuthorizationHeaderValue(tokenType, token)}}
+ wsConn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), headers)
+ if err != nil {
+ return nil, err
+ }
+
+ ws := WebsocketPortForward{ws: wsConn}
+ return &ws, nil
+}
+
+func ExecPortForward(req *PortForwardRequest) {
+ listen, error := net.Listen("tcp", fmt.Sprintf("localhost:%d", req.LocalPort))
+
+ // Handles eventual errors
+ if error != nil {
+ fmt.Println(error)
+ return
+ }
+
+ fmt.Printf("Listening on %s => %d\n", listen.Addr().String(), req.Port)
+
+ for {
+ // Accepts connections
+ con, error := listen.Accept()
+
+ // Handles eventual errors
+ if error != nil {
+ fmt.Println(error)
+ continue
+ }
+
+ go handleConnection(con, req)
+ }
+}
+
+func handleConnection(con net.Conn, req *PortForwardRequest) {
+ var errRet error
+ fmt.Printf("Connection accepted from %s => %d\n", con.RemoteAddr().String(), req.Port)
+ defer func() {
+ if err := con.Close(); err != nil {
+ log.Error("error closing connection: ", err)
+ }
+ fmt.Printf("Connection closed from %s => %d\n", con.RemoteAddr().String(), req.Port)
+ if IsPermanentCloseError(errRet) {
+ log.Error("Port-forward connection rejected: check your permissions or run 'qovery auth'")
+ } else if IsAgentResponseTimeout(errRet) {
+ log.Warnf("Port-forward timed out (agent could not reach the pod or set up the forward). Reconnect to try again.")
+ } else if IsInternalServerError(errRet) {
+ log.Warnf("%s Reconnect to try again.", ServiceUnavailableMessage("Port-forward"))
+ } else if errRet != nil {
+ var e *websocket.CloseError
+ if !errors.As(errRet, &e) || e.Code != websocket.CloseNormalClosure {
+ log.Error("Port-forward connection terminated: ", errRet)
+ }
+ }
+ }()
+
+ wsConn, err := mkWebsocketConn(req)
+ if err != nil {
+ errRet = err
+ log.Errorf("error while creating websocket connection: %v", err)
+ return
+ }
+ defer func() {
+ if err := wsConn.ws.Close(); err != nil {
+ log.Error("error closing websocket connection: ", err)
+ }
+ }()
+
+ go func() {
+ _, _ = io.Copy(wsConn, con)
+ }()
+ _, err = io.Copy(con, wsConn)
+ errRet = err
+}
diff --git a/pkg/promptuifactory/promptuifactory.go b/pkg/promptuifactory/promptuifactory.go
new file mode 100644
index 00000000..b2dafd5a
--- /dev/null
+++ b/pkg/promptuifactory/promptuifactory.go
@@ -0,0 +1,41 @@
+package promptuifactory
+
+import "github.com/manifoldco/promptui"
+
+// PromptUiFactory Used to generate necessary prompts injected into services
+// The purpose is to be able to mock this Factory to be used in Unit Tests
+type PromptUiFactory interface {
+ RunPrompt(label string, defaultValue string) (string, error)
+ RunSelect(label string, items []string) (int, string, error)
+ RunSelectWithSize(label string, items []string, size int) (int, string, error)
+ RunSelectWithSizeAndSearcher(label string, items []string, size int, searcher func(string, int) bool) (int, string, error)
+}
+
+type PromptUiFactoryImpl struct{}
+
+func (factory *PromptUiFactoryImpl) RunPrompt(label string, defaultValue string) (string, error) {
+ return (&promptui.Prompt{
+ Label: label,
+ Default: defaultValue,
+ }).Run()
+}
+func (factory *PromptUiFactoryImpl) RunSelect(label string, items []string) (int, string, error) {
+ return factory.RunSelectWithSize(label, items, 5)
+}
+func (factory *PromptUiFactoryImpl) RunSelectWithSize(label string, items []string, size int) (int, string, error) {
+ return (&promptui.Select{
+ Label: label,
+ Items: items,
+ Size: size,
+ }).Run()
+}
+
+func (factory *PromptUiFactoryImpl) RunSelectWithSizeAndSearcher(label string, items []string, size int, searcher func(string, int) bool) (int, string, error) {
+ return (&promptui.Select{
+ Label: label,
+ Items: items,
+ Size: size,
+ Searcher: searcher,
+ StartInSearchMode: true,
+ }).Run()
+}
diff --git a/pkg/promptuifactory/promptuifactory_mock.go b/pkg/promptuifactory/promptuifactory_mock.go
new file mode 100644
index 00000000..103378ff
--- /dev/null
+++ b/pkg/promptuifactory/promptuifactory_mock.go
@@ -0,0 +1,53 @@
+//go:build testing
+
+package promptuifactory
+
+import (
+ "fmt"
+)
+
+// PromptUiFactoryMock
+type PromptUiFactoryMock struct {
+ // Parameter to trigger an error
+ forceError map[string]bool
+ expectedValueByLabel map[string]string
+}
+
+func NewPromptUiFactoryMock(
+ forceError map[string]bool, // would use a Set but only a Map is available, so use bool as value
+ expectedValueByLabel map[string]string,
+) *PromptUiFactoryMock {
+ return &PromptUiFactoryMock{
+ forceError: forceError,
+ expectedValueByLabel: expectedValueByLabel,
+ }
+}
+
+func (factory *PromptUiFactoryMock) RunPrompt(label string, defaultValue string) (string, error) {
+ _, forceError := factory.forceError[label]
+ if forceError {
+ return "", fmt.Errorf("error for prompt '%s'", label)
+ } else {
+ var value, found = factory.expectedValueByLabel[label]
+ if !found {
+ return defaultValue, nil
+ }
+ return value, nil
+ }
+}
+func (factory *PromptUiFactoryMock) RunSelect(label string, items []string) (int, string, error) {
+ return factory.RunSelectWithSize(label, items, 5)
+}
+func (factory *PromptUiFactoryMock) RunSelectWithSize(label string, items []string, size int) (int, string, error) {
+ return factory.RunSelectWithSizeAndSearcher(label, items, 5, func(string, int) bool { return true })
+}
+
+func (factory *PromptUiFactoryMock) RunSelectWithSizeAndSearcher(label string, items []string, size int, searcher func(string, int) bool) (int, string, error) {
+ _, forceError := factory.forceError[label]
+ if forceError {
+ return -1, "", fmt.Errorf("error for select '%s'", label)
+ } else {
+ var value = factory.expectedValueByLabel[label]
+ return 0, value, nil
+ }
+}
diff --git a/pkg/service_list_pods.go b/pkg/service_list_pods.go
new file mode 100644
index 00000000..f7f56453
--- /dev/null
+++ b/pkg/service_list_pods.go
@@ -0,0 +1,66 @@
+package pkg
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/appscode/go-querystring/query"
+ "github.com/gorilla/websocket"
+ "github.com/qovery/qovery-cli/utils"
+ "net/http"
+ "net/url"
+ "regexp"
+)
+
+type PodResponse struct {
+ Name string
+ Ports []uint16
+}
+type ListPodResponse struct {
+ Pods []PodResponse
+}
+
+func ExecListPods(req *PortForwardRequest) (*ListPodResponse, error) {
+ command, err := query.Values(req)
+ if err != nil {
+ return nil, err
+ }
+
+ wsURL, err := url.Parse(fmt.Sprintf("%s/service/pods", utils.WebsocketUrl()))
+ if err != nil {
+ return nil, err
+ }
+ pattern := regexp.MustCompile("%5B([0-9]+)%5D=")
+ wsURL.RawQuery = pattern.ReplaceAllString(command.Encode(), "[${1}]=")
+
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ headers := http.Header{"Authorization": {utils.GetAuthorizationHeaderValue(tokenType, token)}}
+ wsConn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), headers)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ _ = wsConn.Close()
+ }()
+
+ msgType, payload, err := wsConn.ReadMessage()
+ if err != nil {
+ return nil, err
+ }
+
+ switch msgType {
+ case websocket.TextMessage:
+ var data ListPodResponse
+ err = json.Unmarshal(payload, &data)
+ if err != nil {
+ return nil, err
+ }
+ return &data, nil
+ default:
+ return nil, errors.New("received invalid message while listing pods: " + string(rune(msgType)) + " " + string(payload))
+ }
+}
diff --git a/pkg/shell.go b/pkg/shell.go
index b7b5737d..9cf20f51 100644
--- a/pkg/shell.go
+++ b/pkg/shell.go
@@ -1,115 +1,395 @@
package pkg
import (
+ "context"
+ "errors"
"fmt"
+ "io"
"net/http"
"net/url"
+ "os"
+ "os/signal"
+ "regexp"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+ "github.com/appscode/go-querystring/query"
"github.com/containerd/console"
"github.com/gorilla/websocket"
"github.com/qovery/qovery-cli/utils"
log "github.com/sirupsen/logrus"
+ "golang.org/x/term"
)
const StdinBufferSize = 4096
+const ReconnectDelay = 5 * time.Second
+const PingInterval = 30 * time.Second
+
+// ReadTimeout must be > 2 Ã PingInterval so that a healthy connection always receives a pong
+// before the deadline fires. The pong handler resets the deadline on every pong received.
+const ReadTimeout = 75 * time.Second
+
+type TerminalSize interface {
+ SetTtySize(width uint16, height uint16)
+}
type ShellRequest struct {
- ApplicationID utils.Id
- EnvironmentID utils.Id
- ProjectID utils.Id
- OrganizationID utils.Id
- ClusterID utils.Id
+ ServiceID utils.Id `url:"service"`
+ EnvironmentID utils.Id `url:"environment"`
+ ProjectID utils.Id `url:"project"`
+ OrganizationID utils.Id `url:"organization"`
+ ClusterID utils.Id `url:"cluster"`
+ PodName string `url:"pod_name,omitempty"`
+ ContainerName string `url:"container_name,omitempty"`
+ Command []string `url:"command"`
+ TtyWidth uint16 `url:"tty_width"`
+ TtyHeight uint16 `url:"tty_height"`
+ EphemeralMode string `url:"mode,omitempty"`
+ CpuOverride string `url:"cpu_override,omitempty"`
+ MemoryOverride string `url:"memory_override,omitempty"`
}
-func ExecShell(req *ShellRequest) {
- wsConn, err := createWebsocketConn(req)
- if err != nil {
- log.Fatal("error while creating websocket connection", err)
- }
- defer func() {
- if err := wsConn.Close(); err != nil {
- log.Fatal("error while closing websocket connection", err)
- }
+func (s *ShellRequest) SetTtySize(width uint16, height uint16) {
+ s.TtyWidth = width
+ s.TtyHeight = height
+}
+
+func ExecShell(req TerminalSize, path string) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ var wg sync.WaitGroup
+ var userCancelled atomic.Bool
+ var normalExit atomic.Bool
+
+ signalChan := make(chan os.Signal, 1)
+ signal.Notify(signalChan, syscall.SIGTERM)
+ go func() {
+ <-signalChan
+ userCancelled.Store(true)
+ cancel()
}()
- currentConsole := console.Current()
- if err := currentConsole.SetRaw(); err != nil {
- log.Fatal("error while setting up console", err)
+ // Allocate a PTY only when stdin is a real terminal. When piped (e.g.
+ // `qovery shell --command ... < input` or invoked from automation),
+ // containerd/console.Current() panics with "provided file is not a console".
+ // In that case we behave like `kubectl exec -i`: pipe os.Stdin/os.Stdout
+ // straight through and leave TtyWidth/TtyHeight at zero so the server
+ // does not attempt to allocate a TTY on the remote side either.
+ interactive := term.IsTerminal(int(os.Stdin.Fd()))
+
+ var stdinReader io.Reader = os.Stdin
+ var stdoutWriter io.Writer = os.Stdout
+
+ if interactive {
+ currentConsole := console.Current()
+ defer func() {
+ _ = currentConsole.Reset()
+ }()
+
+ if err := currentConsole.SetRaw(); err != nil {
+ log.Fatal("error while setting up console", err)
+ }
+
+ winSize, err := currentConsole.Size()
+ if err != nil {
+ log.Fatal("Cannot get terminal size", err)
+ }
+ req.SetTtySize(winSize.Width, winSize.Height)
+
+ stdinReader = currentConsole
+ stdoutWriter = currentConsole
}
- done := make(chan struct{})
stdIn := make(chan []byte)
-
- go readWebsocketConnection(wsConn, currentConsole, done)
- go readUserConsole(currentConsole, stdIn, done)
+ wg.Add(1)
+ go readUserConsole(ctx, cancel, stdinReader, interactive, stdIn, &normalExit, &wg)
for {
- select {
- case <-done:
- return
- case msg := <-stdIn:
- if err := wsConn.WriteMessage(websocket.BinaryMessage, msg); err != nil {
- log.Error("error while writing on websocket:", err)
- return
+ if ctx.Err() != nil || userCancelled.Load() || normalExit.Load() {
+ log.Info("Shell exited, not reconnecting.")
+ break
+ }
+
+ log.Info("Attempting to (re)connect")
+ var requestId string
+
+ wsConn, resp, err := createWebsocketConn(req, path)
+ if resp != nil {
+ requestId = resp.Header.Get("X-Qovery-Request-Id")
+ log.Info("Connected to shell with requestId: ", requestId)
+ }
+ if err != nil {
+ log.Errorf("WebSocket connection failed: %v %s", err, requestId)
+ if ctx.Err() != nil || userCancelled.Load() || normalExit.Load() {
+ log.Info("User cancelled or shell exited during connection attempt.")
+ break
+ }
+ time.Sleep(ReconnectDelay)
+ continue
+ }
+ done := make(chan struct{})
+ wg.Add(1)
+ go readWebsocketConnection(ctx, cancel, wsConn, requestId, stdoutWriter, done, &normalExit, &wg)
+
+ pingTicker := time.NewTicker(PingInterval)
+
+ wsLoop:
+ for {
+ select {
+ case <-ctx.Done():
+ _ = wsConn.Close()
+ break wsLoop
+ case <-done:
+ _ = wsConn.Close()
+ break wsLoop
+ case msg := <-stdIn:
+ if err := wsConn.WriteMessage(websocket.BinaryMessage, msg); err != nil {
+ log.Error("Write error:", err)
+ _ = wsConn.Close()
+ break wsLoop
+ }
+ case <-pingTicker.C:
+ if err := wsConn.WriteMessage(websocket.PingMessage, nil); err != nil {
+ log.Error("Ping error:", err)
+ _ = wsConn.Close()
+ break wsLoop
+ }
+ }
+
+ if normalExit.Load() || userCancelled.Load() || ctx.Err() != nil {
+ break wsLoop
}
}
+
+ pingTicker.Stop()
+
+ // Cancel the context to notify readUserConsole
+ if normalExit.Load() || userCancelled.Load() {
+ cancel()
+ }
+
+ // Do NOT close stdIn â readUserConsole owns it and it is used across reconnects.
+ if ctx.Err() == nil && !normalExit.Load() && !userCancelled.Load() {
+ time.Sleep(ReconnectDelay)
+ }
}
+
+ wg.Wait()
}
-func createWebsocketConn(req *ShellRequest) (*websocket.Conn, error) {
- wsURL, err := url.Parse(fmt.Sprintf(
- "wss://ws.qovery.com/shell/exec?application=%s&cluster=%s&environment=%s&organization=%s&project=%s",
- req.ApplicationID,
- req.ClusterID,
- req.EnvironmentID,
- req.OrganizationID,
- req.ProjectID,
- ))
+func createWebsocketConn(req interface{}, path string) (*websocket.Conn, *http.Response, error) {
+ command, err := query.Values(req)
if err != nil {
- return nil, err
+ return nil, nil, err
}
- token, err := utils.GetAccessToken()
+ wsURL, err := url.Parse(fmt.Sprintf("%s%s", utils.WebsocketUrl(), path))
if err != nil {
- return nil, err
+ return nil, nil, err
}
+ pattern := regexp.MustCompile("%5B([0-9]+)%5D=")
+ wsURL.RawQuery = pattern.ReplaceAllString(command.Encode(), "[${1}]=")
- headers := http.Header{"Authorization": {fmt.Sprintf("Bearer %s", token)}}
- wsConn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), headers)
+ tokenType, token, err := utils.GetAccessToken(false)
if err != nil {
- return nil, err
+ return nil, nil, err
}
- return wsConn, nil
+
+ headers := http.Header{"Authorization": {utils.GetAuthorizationHeaderValue(tokenType, token)}}
+ conn, resp, err := websocket.DefaultDialer.Dial(wsURL.String(), headers)
+ return conn, resp, err
}
-func readWebsocketConnection(wsConn *websocket.Conn, currentConsole console.Console, done chan struct{}) {
- defer close(done)
+func readWebsocketConnection(ctx context.Context, cancel context.CancelFunc, wsConn *websocket.Conn, requestId string, out io.Writer, done chan struct{}, normalExit *atomic.Bool, wg *sync.WaitGroup) {
+ defer wg.Done()
+
+ var once sync.Once
+ safeClose := func() {
+ once.Do(func() {
+ select {
+ case <-done:
+ // already closed
+ default:
+ close(done)
+ }
+ })
+ }
+ defer safeClose()
+
+ // Set an initial read deadline. The pong handler refreshes it on every
+ // pong so that idle-but-healthy sessions are not torn down; only truly
+ // dead connections (no pong for ReadTimeout) are detected and closed.
+ _ = wsConn.SetReadDeadline(time.Now().Add(ReadTimeout))
+ // SetReadDeadline failure in the pong handler would surface as a ReadMessage error on
+ // the next iteration, but cannot happen on a healthy net.Conn.
+ wsConn.SetPongHandler(func(string) error {
+ return wsConn.SetReadDeadline(time.Now().Add(ReadTimeout))
+ })
+
for {
- _, msg, err := wsConn.ReadMessage()
- if err != nil {
- if e, ok := err.(*websocket.CloseError); ok {
- log.Error("connection closed by server: ", e)
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ msgType, msg, err := wsConn.ReadMessage()
+ if err != nil {
+ var e *websocket.CloseError
+ if !errors.As(err, &e) {
+ log.Errorf("error while reading on websocket %s: %v ", requestId, err)
+ return
+ }
+ switch {
+ case e.Code == websocket.CloseNormalClosure:
+ log.Info("** shell terminated bye **")
+ normalExit.Store(true)
+ case e.Code == 1007 || e.Code == 1008: // same as IsPermanentCloseError
+ log.Errorf("Shell connection %s rejected: check your permissions or run 'qovery auth'", requestId)
+ cancel()
+ case IsAgentResponseTimeout(err): // must come before generic 1011 branch
+ log.Warnf("Shell session %s timed out while the agent was preparing your connection. Retrying...", requestId)
+ case e.Code == 1011:
+ log.Warnf("%s Closing %s and Retrying...", ServiceUnavailableMessage("Shell"), requestId)
+ default:
+ log.Errorf("connection %s closed by server: %v", requestId, e)
+ }
+ return
+ }
+
+ if msgType == websocket.CloseMessage {
+ normalExit.Store(true)
+ return
+ }
+
+ if msgType != websocket.BinaryMessage {
+ continue
+ }
+
+ if _, err = out.Write(msg); err != nil {
+ log.Errorf("error while writing in console: %v", err)
return
}
- log.Error("error while reading on websocket:", err)
- return
- }
- if _, err = currentConsole.Write(msg); err != nil {
- log.Error("error while writing in console:", err)
- return
}
}
}
-func readUserConsole(currentConsole console.Console, stdIn chan []byte, done chan struct{}) {
- defer close(done)
+func readUserConsole(ctx context.Context, cancel context.CancelFunc, in io.Reader, interactive bool, stdIn chan []byte, normalExit *atomic.Bool, wg *sync.WaitGroup) {
+ defer wg.Done()
+
buffer := make([]byte, StdinBufferSize)
+ // Persistent buffer to handle fragmented bracketed paste sequences
+ var pendingBytes []byte
+
for {
- count, err := currentConsole.Read(buffer)
+ if ctx.Err() != nil || normalExit.Load() {
+ return
+ }
+
+ count, err := in.Read(buffer)
if err != nil {
+ // In non-interactive mode (piped stdin), EOF means the input stream
+ // is exhausted but the remote command may still produce output. We
+ // flush any buffered bytes, then send EOT (0x04) so the remote PTY
+ // line discipline propagates EOF to the remote process. Without
+ // this, commands like `cat < file` or `sh -s < script.sh` hang
+ // forever. The websocket loop stays open so we can drain remote
+ // stdout until the server closes the session.
+ if !interactive && errors.Is(err, io.EOF) {
+ if len(pendingBytes) > 0 {
+ select {
+ case <-ctx.Done():
+ return
+ case stdIn <- pendingBytes:
+ }
+ }
+ select {
+ case <-ctx.Done():
+ case stdIn <- []byte{0x04}:
+ }
+ return
+ }
log.Error("error while reading on console:", err)
+ cancel()
return
}
- stdIn <- buffer[0:count]
+
+ // Do not handle Ctrl^C in order to be able to kill commands inside the container
+ // if count > 0 && buffer[0] == 3 { // Ctrl+C
+ // log.Info("Detected Ctrl+C from user input, exiting gracefully...")
+ // cancel()
+ // return
+ // }
+
+ // Combine pending bytes from previous read with new data
+ data := append(pendingBytes, buffer[0:count]...)
+
+ // Handle fragmentation of bracketed paste sequences
+ // Instead of filtering them out, we ensure they are sent complete
+ toSend, pending := handleBracketedPasteFragmentation(data)
+ pendingBytes = pending
+
+ if len(toSend) > 0 {
+ select {
+ case <-ctx.Done():
+ return
+ case stdIn <- toSend:
+ }
+ }
+ }
+}
+
+// handleBracketedPasteFragmentation ensures bracketed paste sequences are sent complete
+// to prevent Terminal.app's fragmentation issues from corrupting the stream.
+// If a potential sequence is incomplete at the end, it's buffered for the next read.
+// Returns: (data to send, pending bytes that might be part of an incomplete sequence)
+func handleBracketedPasteFragmentation(data []byte) ([]byte, []byte) {
+ // Look for ESC at the end that could be the start of a bracketed paste sequence
+ // The sequences are: ESC[200~ (start) and ESC[201~ (end)
+ // We need to check if we have a potentially incomplete sequence at the end
+
+ if len(data) == 0 {
+ return data, nil
+ }
+
+ // Check if the end of data could be the start of a bracketed paste sequence
+ // ESC[200~ or ESC[201~ are 6 bytes long
+ for checkLen := 1; checkLen < 6 && checkLen <= len(data); checkLen++ {
+ tail := data[len(data)-checkLen:]
+
+ // Check if this could be the start of ESC[200~ or ESC[201~
+ if isPotentialBracketedPastePrefix(tail) {
+ // Buffer these bytes for the next read
+ return data[:len(data)-checkLen], tail
+ }
}
+
+ // No incomplete sequence detected, send everything
+ return data, nil
+}
+
+// isPotentialBracketedPastePrefix checks if data could be the start of a bracketed paste sequence
+func isPotentialBracketedPastePrefix(data []byte) bool {
+ bracketedPasteStart := []byte{0x1b, '[', '2', '0', '0', '~'}
+ bracketedPasteEnd := []byte{0x1b, '[', '2', '0', '1', '~'}
+
+ if len(data) == 0 || len(data) >= 6 {
+ return false
+ }
+
+ // Check if it matches the start of either sequence
+ matchesStart := true
+ matchesEnd := true
+
+ for i := 0; i < len(data); i++ {
+ if data[i] != bracketedPasteStart[i] {
+ matchesStart = false
+ }
+ if data[i] != bracketedPasteEnd[i] {
+ matchesEnd = false
+ }
+ }
+
+ return matchesStart || matchesEnd
}
diff --git a/pkg/update.go b/pkg/update.go
index 2a937700..da94ec16 100644
--- a/pkg/update.go
+++ b/pkg/update.go
@@ -5,21 +5,20 @@ import (
"fmt"
"github.com/qovery/qovery-cli/utils"
log "github.com/sirupsen/logrus"
- "io/ioutil"
+ "io"
"net/http"
"os"
"strings"
)
func UpdateById(clusterId string, dryRunDisabled bool, version string) {
- utils.CheckAdminUrl()
utils.DryRunPrint(dryRunDisabled)
if utils.Validate("update") {
- res := update(utils.AdminUrl+"/cluster/update/"+clusterId, http.MethodPost, dryRunDisabled, version, "", 0)
+ res := update(utils.GetAdminUrl()+"/cluster/update/"+clusterId, http.MethodPost, dryRunDisabled, version, "", 0)
if !strings.Contains(res.Status, "200") {
- result, _ := ioutil.ReadAll(res.Body)
+ result, _ := io.ReadAll(res.Body)
log.Errorf("Could not update cluster : %s. %s", res.Status, string(result))
} else if !dryRunDisabled {
fmt.Println("Cluster " + clusterId + " updatable.")
@@ -30,42 +29,39 @@ func UpdateById(clusterId string, dryRunDisabled bool, version string) {
}
func UpdateAll(dryRunDisabled bool, version string, providerKind string, parallelRun int) {
- utils.CheckAdminUrl()
utils.DryRunPrint(dryRunDisabled)
if utils.Validate("update") {
- res := update(utils.AdminUrl+"/cluster/update", http.MethodPost, dryRunDisabled, version, providerKind, parallelRun)
-
- if !strings.Contains(res.Status, "200") {
- result, _ := ioutil.ReadAll(res.Body)
+ res := update(utils.GetAdminUrl()+"/cluster/update", http.MethodPost, dryRunDisabled, version, providerKind, parallelRun)
+ result, _ := io.ReadAll(res.Body)
+ if strings.Contains(res.Status, "40") || strings.Contains(res.Status, "50") {
log.Errorf("Could not update clusters : %s. %s", res.Status, string(result))
- } else if !dryRunDisabled {
- fmt.Println("Clusters updatable.")
} else {
- fmt.Println("Clusters updating.")
+ depl := "Deployable"
+ if dryRunDisabled {
+ depl = "Deploying"
+ }
+ log.Infof("%s clusters: %s", depl, result)
}
}
}
func update(url string, method string, dryRunDisabled bool, version string, providerKind string, parallelRun int) *http.Response {
- authToken, tokenErr := utils.GetAccessToken()
- if tokenErr != nil {
- utils.PrintlnError(tokenErr)
+ tokenType, token, err := utils.GetAccessToken(false)
+ if err != nil {
+ utils.PrintlnError(err)
os.Exit(0)
}
- body := bytes.NewBuffer([]byte(`{ "metadata": { "dry_run_deploy": true } }`))
-
- if dryRunDisabled {
- body = bytes.NewBuffer([]byte(fmt.Sprintf(`{ "metadata": { "dry_run_deploy": false, "target_version": "%s", "provider_kind": "%s", "parallel_run": %d } }`, version, providerKind, parallelRun)))
- }
+ content := fmt.Sprintf(`{ "metadata": { "dry_run_deploy": %t, "target_version": "%s", "provider_kind": "%s", "parallel_run": %d } }`, !dryRunDisabled, version, providerKind, parallelRun)
+ body := bytes.NewBuffer([]byte(content))
req, err := http.NewRequest(method, url, body)
if err != nil {
log.Fatal(err)
}
- req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(authToken)))
+ req.Header.Set("Authorization", utils.GetAuthorizationHeaderValue(tokenType, token))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
diff --git a/pkg/usercontext/organization_context.go b/pkg/usercontext/organization_context.go
new file mode 100644
index 00000000..ee238c6f
--- /dev/null
+++ b/pkg/usercontext/organization_context.go
@@ -0,0 +1,35 @@
+package usercontext
+
+import (
+ "context"
+ "github.com/go-errors/errors"
+ "github.com/qovery/qovery-client-go"
+ "strings"
+
+ "github.com/qovery/qovery-cli/utils"
+)
+
+func GetOrganizationContextResourceId(qoveryAPIClient *qovery.APIClient, organizationName string) (string, error) {
+ if strings.TrimSpace(organizationName) == "" {
+ id, _, err := utils.CurrentOrganization(true)
+ if err != nil {
+ return "", err
+ }
+
+ return string(id), nil
+ }
+
+ // find organization by name
+ organizations, _, err := qoveryAPIClient.OrganizationMainCallsAPI.ListOrganization(context.Background()).Execute()
+
+ if err != nil {
+ return "", err
+ }
+
+ organization := utils.FindByOrganizationName(organizations.GetResults(), organizationName)
+ if organization == nil {
+ return "", errors.Errorf("organization %s not found", organizationName)
+ }
+
+ return organization.Id, nil
+}
diff --git a/pkg/vault.go b/pkg/vault.go
deleted file mode 100644
index 6ff10739..00000000
--- a/pkg/vault.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package pkg
-
-import (
- b64 "encoding/base64"
- "github.com/hashicorp/vault/api"
- "github.com/qovery/qovery-cli/utils"
- log "github.com/sirupsen/logrus"
- "os"
- "strings"
-)
-
-func connectToVault() *api.Client {
- var token = os.Getenv("VAULT_TOKEN")
- var vaultAddr = os.Getenv("VAULT_ADDR")
-
- config := &api.Config{
- Address: vaultAddr,
- }
- client, err := api.NewClient(config)
-
- if err != nil {
- log.Error("Can't create Vault client : " + err.Error())
- return nil
- }
-
- client.SetToken(token)
-
- return client
-}
-
-func getClusterPath(client *api.Client, clusterID string) string {
- result, err := client.Logical().List("official-clusters-access/metadata")
- if err != nil {
- log.Error(err)
- }
-
- for _, secret := range (result.Data["keys"]).([]interface{}) {
- if strings.Contains(secret.(string), clusterID) {
- return secret.(string)
- }
- }
-
- return ""
-}
-
-func GetVarsByClusterId(clusterID string) []utils.Var {
- client := connectToVault()
- path := getClusterPath(client, clusterID)
-
- result, err := client.Logical().Read("official-clusters-access/data/" + path)
- if err != nil {
- log.Error(err)
- }
-
- var vaultVars []utils.Var
- for key, value := range (result.Data["data"]).(map[string]interface{}) {
- switch key {
- case "AWS_ACCESS_KEY_ID":
- vaultVars = append(vaultVars, utils.Var{Key: key, Value: value.(string)})
- case "AWS_DEFAULT_REGION":
- vaultVars = append(vaultVars, utils.Var{Key: key, Value: value.(string)})
- case "AWS_SECRET_ACCESS_KEY":
- vaultVars = append(vaultVars, utils.Var{Key: key, Value: value.(string)})
- case "KUBECONFIG_b64":
- decodedValue, encErr := b64.StdEncoding.DecodeString(value.(string))
- if encErr != nil {
- log.Error("Can't decode KUBECONFIG")
- return []utils.Var{}
- }
- filePath := utils.WriteInFile(clusterID, "kubeconfig", decodedValue)
- vaultVars = append(vaultVars, utils.Var{Key: "KUBECONFIG", Value: filePath})
- }
- }
-
- return vaultVars
-}
diff --git a/pkg/version.go b/pkg/version.go
index cb22572c..813716b7 100644
--- a/pkg/version.go
+++ b/pkg/version.go
@@ -7,38 +7,57 @@ import (
"os"
"strings"
+ semver "github.com/Masterminds/semver/v3"
+
"github.com/qovery/qovery-cli/utils"
)
-func GetCurrentVersion() string {
- return "0.43.0" // ci-version-check
+func GetCurrentVersion() (*semver.Version, error) {
+ version, err := semver.NewVersion(utils.Version)
+ if err != nil {
+ return nil, fmt.Errorf("error trying to get semver from raw string `%s`, error: `%w`", utils.Version, err)
+ }
+
+ return version, nil
}
func GetLatestOnlineVersionUrl() (string, error) {
url := "https://github.com/Qovery/qovery-cli/releases/latest"
resp, err := http.Get(url)
if err != nil {
- return "", errors.New("Can't reach Github, please check your network connectivity. ")
+ return "", errors.New("can't reach Github, please check your network connectivity")
}
+
return resp.Request.URL.Path, nil
}
-func GetLatestOnlineVersionNumber() (string, error) {
+func GetLatestOnlineVersionNumber() (*semver.Version, error) {
urlPath, err := GetLatestOnlineVersionUrl()
if err != nil {
utils.PrintlnError(err)
os.Exit(0)
}
splitUrl := strings.Split(urlPath, "/v")
- return splitUrl[len(splitUrl)-1], nil
+
+ version, err := semver.NewVersion(splitUrl[len(splitUrl)-1])
+ if err != nil {
+ utils.PrintlnError(err)
+ os.Exit(0)
+ }
+
+ return version, nil
}
-func CheckAvailableNewVersion() (bool, string, string) {
+func CheckAvailableNewVersion() (bool, string, *semver.Version) {
latestOnlineVersion, err := GetLatestOnlineVersionNumber()
if err != nil {
- return false, "Error while trying to get the latest version. ", ""
+ return false, "Error while trying to get the latest version. ", nil
+ }
+ currentVersion, err := GetCurrentVersion()
+ if err != nil {
+ return false, fmt.Sprintf("Error while trying to get the current version, mostlikely current version `%s` is not a valid semver string, error: `%s`", utils.Version, err), nil
}
- if GetCurrentVersion() < latestOnlineVersion {
+ if latestOnlineVersion.GreaterThan(currentVersion) {
return true, fmt.Sprintf("A new version has been found %s, please upgrade it. \n"+
"You can use your package manager or 'qovery upgrade' command. ",
latestOnlineVersion), latestOnlineVersion
diff --git a/pkg/wserror.go b/pkg/wserror.go
new file mode 100644
index 00000000..d36f0e88
--- /dev/null
+++ b/pkg/wserror.go
@@ -0,0 +1,68 @@
+package pkg
+
+import (
+ "errors"
+ "strings"
+
+ "github.com/gorilla/websocket"
+)
+
+// IsPermanentCloseError returns true if the websocket close error should NOT
+// be retried (permission denied, auth/policy violation).
+// Transient errors (abnormal closure, going away, internal server error) return false.
+func IsPermanentCloseError(err error) bool {
+ var closeErr *websocket.CloseError
+ if !errors.As(err, &closeErr) {
+ return false
+ }
+ switch closeErr.Code {
+ case 1007: // Invalid frame payload data â used by gateway for permission errors
+ return true
+ case 1008: // Policy Violation â used for auth/token errors
+ return true
+ default:
+ return false
+ }
+}
+
+// IsInternalServerError returns true if the websocket close error is code 1011 (Internal Error).
+func IsInternalServerError(err error) bool {
+ var closeErr *websocket.CloseError
+ if !errors.As(err, &closeErr) {
+ return false
+ }
+ return closeErr.Code == 1011
+}
+
+// IsAgentResponseTimeout returns true if the websocket close error indicates
+// that K8s operations on the shell-agent side timed out, or that the gateway
+// timed out waiting for the agent to respond. All are transient and resolve
+// once the pod's Kubernetes exec API is responsive again.
+//
+// IsAgentResponseTimeout is a strict subset of IsInternalServerError (both match close code 1011).
+// Always check IsAgentResponseTimeout before IsInternalServerError, otherwise the specific timeout
+// message is swallowed by the generic 1011 branch.
+//
+// Matched substrings and their sources:
+// - "exceeded for receiving agent response" â gateway wait (shell_gateway.rs DEFAULT_AGENT_RESPONSE_TIMEOUT)
+// - "while connecting to pod" â shell-agent K8s exec timeout (shell.rs KUBE_OPERATION_TIMEOUT)
+// - "while setting up port forward" â shell-agent K8s port-forward timeout (port_forward.rs KUBE_PORT_FORWARD_TIMEOUT)
+// - "Retry budget exhausted" â shell-agent retry budget guard (shell.rs / port_forward.rs)
+func IsAgentResponseTimeout(err error) bool {
+ var closeErr *websocket.CloseError
+ if !errors.As(err, &closeErr) {
+ return false
+ }
+ if closeErr.Code != 1011 {
+ return false
+ }
+ return strings.Contains(closeErr.Text, "exceeded for receiving agent response") ||
+ strings.Contains(closeErr.Text, "while connecting to pod") ||
+ strings.Contains(closeErr.Text, "while setting up port forward") ||
+ strings.Contains(closeErr.Text, "Retry budget exhausted")
+}
+
+// ServiceUnavailableMessage returns a user-friendly message when the cluster agent is unreachable.
+func ServiceUnavailableMessage(feature string) string {
+ return feature + " is not available. Please verify that the cluster hosting this service is running and healthy."
+}
diff --git a/pkg/wserror_test.go b/pkg/wserror_test.go
new file mode 100644
index 00000000..756768c6
--- /dev/null
+++ b/pkg/wserror_test.go
@@ -0,0 +1,154 @@
+package pkg
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/gorilla/websocket"
+)
+
+func TestIsAgentResponseTimeout(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {
+ name: "1011 gateway wait timeout",
+ err: &websocket.CloseError{Code: 1011, Text: "Deadline of 90s exceeded for receiving agent response"},
+ want: true,
+ },
+ {
+ name: "1011 shell-agent K8s exec timeout",
+ err: &websocket.CloseError{Code: 1011, Text: "Timed out after 45s while connecting to pod"},
+ want: true,
+ },
+ {
+ name: "1011 shell-agent K8s port-forward timeout",
+ err: &websocket.CloseError{Code: 1011, Text: "Timed out after 45s while setting up port forward"},
+ want: true,
+ },
+ {
+ name: "1011 shell-agent retry budget exhausted (exec)",
+ err: &websocket.CloseError{Code: 1011, Text: "Retry budget exhausted: only 2s remaining, need at least 45s for K8s exec setup"},
+ want: true,
+ },
+ {
+ name: "1011 shell-agent retry budget exhausted (port-forward)",
+ err: &websocket.CloseError{Code: 1011, Text: "Retry budget exhausted: only 1s remaining, need at least 45s for K8s port-forward setup"},
+ want: true,
+ },
+ {
+ name: "1011 with different reason falls through to IsInternalServerError",
+ err: &websocket.CloseError{Code: 1011, Text: "some other internal error"},
+ want: false,
+ },
+ {
+ name: "wrong close code",
+ err: &websocket.CloseError{Code: 1007, Text: "exceeded for receiving agent response"},
+ want: false,
+ },
+ {
+ name: "non-websocket error",
+ err: errors.New("plain network error"),
+ want: false,
+ },
+ {
+ name: "wrapped 1011 gateway timeout",
+ err: fmt.Errorf("read failed: %w", &websocket.CloseError{Code: 1011, Text: "Deadline of 90s exceeded for receiving agent response"}),
+ want: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := IsAgentResponseTimeout(tt.err); got != tt.want {
+ t.Errorf("IsAgentResponseTimeout() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+// TestIsAgentResponseTimeoutBeforeIsInternalServerError verifies that a 1011 close error with
+// a timeout message matches BOTH IsAgentResponseTimeout (true) and IsInternalServerError (true),
+// since timeout is a strict subset of 1011. The test documents why IsAgentResponseTimeout must
+// always be checked first in the error-handling chain â otherwise the specific timeout message
+// is swallowed by the generic 1011 branch.
+func TestIsAgentResponseTimeoutBeforeIsInternalServerError(t *testing.T) {
+ for _, text := range []string{
+ "Deadline of 90s exceeded for receiving agent response",
+ "Timed out after 45s while connecting to pod",
+ "Timed out after 45s while setting up port forward",
+ "Retry budget exhausted: only 2s remaining, need at least 45s for K8s exec setup",
+ "Retry budget exhausted: only 1s remaining, need at least 45s for K8s port-forward setup",
+ } {
+ err := &websocket.CloseError{Code: 1011, Text: text}
+ if !IsAgentResponseTimeout(err) {
+ t.Errorf("IsAgentResponseTimeout(%q) = false, want true", text)
+ }
+ if !IsInternalServerError(err) {
+ t.Errorf("IsInternalServerError(%q) = false, want true (timeout is a subset of 1011)", text)
+ }
+ }
+}
+
+func TestIsInternalServerError(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {"1011 matches", &websocket.CloseError{Code: 1011, Text: "anything"}, true},
+ {"1007 does not match", &websocket.CloseError{Code: 1007, Text: ""}, false},
+ {"non-websocket error", errors.New("plain error"), false},
+ {"wrapped 1011", fmt.Errorf("wrap: %w", &websocket.CloseError{Code: 1011, Text: "x"}), true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := IsInternalServerError(tt.err); got != tt.want {
+ t.Errorf("IsInternalServerError() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsPermanentCloseError(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {"1007 is permanent", &websocket.CloseError{Code: 1007}, true},
+ {"1008 is permanent", &websocket.CloseError{Code: 1008}, true},
+ {"1011 is transient", &websocket.CloseError{Code: 1011}, false},
+ {"1000 is transient", &websocket.CloseError{Code: 1000}, false},
+ {"non-websocket error", errors.New("plain error"), false},
+ {"wrapped 1008", fmt.Errorf("wrap: %w", &websocket.CloseError{Code: 1008}), true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := IsPermanentCloseError(tt.err); got != tt.want {
+ t.Errorf("IsPermanentCloseError() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestServiceUnavailableMessage(t *testing.T) {
+ for _, feature := range []string{"Shell", "Port-forward"} {
+ msg := ServiceUnavailableMessage(feature)
+ if !strings.HasPrefix(msg, feature) {
+ t.Errorf("ServiceUnavailableMessage(%q): expected prefix %q, got: %q", feature, feature, msg)
+ }
+ if !strings.Contains(msg, "cluster") {
+ t.Errorf("ServiceUnavailableMessage(%q): expected 'cluster' in message, got: %q", feature, msg)
+ }
+ if !strings.Contains(msg, "running") {
+ t.Errorf("ServiceUnavailableMessage(%q): expected 'running' in message, got: %q", feature, msg)
+ }
+ if !strings.HasSuffix(msg, ".") {
+ t.Errorf("ServiceUnavailableMessage(%q): expected message to end with '.', got: %q", feature, msg)
+ }
+ }
+}
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 00000000..0e058f8a
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,11 @@
+# https://github.com/edolstra/flake-compat
+(import
+ (
+ let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
+ fetchTarball {
+ url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
+ sha256 = lock.nodes.flake-compat.locked.narHash;
+ }
+ )
+ { src = ./.; }
+).shellNix
diff --git a/utils/auth.go b/utils/auth.go
index 9355adfc..10862294 100644
--- a/utils/auth.go
+++ b/utils/auth.go
@@ -3,6 +3,7 @@ package utils
import (
"encoding/json"
"errors"
+ "io"
"net/http"
"net/url"
"strings"
@@ -10,8 +11,8 @@ import (
)
type TokensResponse struct {
- AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token"`
+ AccessToken string `json:"access_token"`
+ ExpiresIn uint `json:"expires_in"`
}
var (
@@ -19,11 +20,10 @@ var (
oAuthTokenEndpoint = "https://auth.qovery.com/oauth/token"
)
-func RefreshAccessToken() error {
- token, _ := GetRefreshToken()
+func RefreshAccessToken(token RefreshToken) (AccessToken, error) {
refreshToken := strings.TrimSpace(string(token))
if refreshToken == "" {
- return errors.New("Could not reauthenticate automatically. Please, run 'qovery auth' to authenticate. ")
+ return "", errors.New("could not reauthenticate automatically. Please, run 'qovery auth' to authenticate. ")
}
res, err := http.PostForm(oAuthTokenEndpoint, url.Values{
"grant_type": {"refresh_token"},
@@ -31,25 +31,23 @@ func RefreshAccessToken() error {
"refresh_token": {refreshToken},
})
if err != nil {
- return errors.New("Error authenticating in Qovery. Please, contact the #support on 'https://discord.qovery.com'. ")
- } else {
- defer res.Body.Close()
- tokens := TokensResponse{}
- err := json.NewDecoder(res.Body).Decode(&tokens)
- if err != nil {
- return errors.New("Error authenticating in Qovery. Please, contact the #support on 'https://discord.qovery.com'. ")
- }
- expiredAt := time.Now().Local().Add(time.Second * time.Duration(30000))
- _ = SetAccessToken(AccessToken(tokens.AccessToken), expiredAt)
+ return "", errors.New("error authenticating in Qovery. Please, contact the #support on 'https://discord.qovery.com'. ")
}
- return nil
-}
-func RefreshExpiredTokenSilently() {
- token, _ := GetRefreshToken()
- refreshToken := strings.TrimSpace(string(token))
- expiration, err := GetAccessTokenExpiration()
- if err == nil && expiration.Before(time.Now()) && refreshToken != "" {
- _ = RefreshAccessToken()
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(res.Body)
+
+ tokens := TokensResponse{}
+ err = json.NewDecoder(res.Body).Decode(&tokens)
+ if err != nil {
+ return "", errors.New("error authenticating in Qovery. Please, contact the #support on 'https://discord.qovery.com'. ")
}
+ expiredAt := time.Now().Local().Add(time.Duration(tokens.ExpiresIn-60) * time.Second)
+ accessToken := AccessToken(tokens.AccessToken)
+ // We dont have refreshToken rotation enabled, we should, ...
+ // So the response does not contain a new refresh token to use. We keep the old one
+ _ = SetAccessToken(accessToken, expiredAt, token)
+
+ return accessToken, nil
}
diff --git a/utils/autoscaling.go b/utils/autoscaling.go
new file mode 100644
index 00000000..da8f3807
--- /dev/null
+++ b/utils/autoscaling.go
@@ -0,0 +1,44 @@
+package utils
+
+import (
+ "github.com/qovery/qovery-client-go"
+)
+
+// ConvertAutoscalingResponseToRequest converts an AutoscalingPolicyResponse (from the API)
+// into an AutoscalingPolicyRequest suitable for update calls, preserving existing KEDA config.
+func ConvertAutoscalingResponseToRequest(resp *qovery.AutoscalingPolicyResponse) *qovery.AutoscalingPolicyRequest {
+ if resp == nil || resp.KedaAutoscalingResponse == nil {
+ return nil
+ }
+
+ kedaResp := resp.KedaAutoscalingResponse
+
+ var scalers []qovery.KedaScalerRequest
+ for _, s := range kedaResp.Scalers {
+ enabled := s.Enabled
+ scaler := qovery.KedaScalerRequest{
+ ScalerType: s.ScalerType,
+ Enabled: &enabled,
+ Role: s.Role,
+ ConfigJson: s.ConfigJson,
+ ConfigYaml: s.ConfigYaml.Get(),
+ }
+ if s.TriggerAuthentication != nil {
+ scaler.TriggerAuthentication = &qovery.KedaTriggerAuthenticationRequest{
+ Name: s.TriggerAuthentication.Name,
+ ConfigYaml: s.TriggerAuthentication.ConfigYaml,
+ }
+ }
+ scalers = append(scalers, scaler)
+ }
+
+ kedaReq := &qovery.KedaAutoscalingRequest{
+ Mode: kedaResp.Mode,
+ PollingIntervalSeconds: &kedaResp.PollingIntervalSeconds,
+ CooldownPeriodSeconds: &kedaResp.CooldownPeriodSeconds,
+ Scalers: scalers,
+ }
+
+ result := qovery.KedaAutoscalingRequestAsAutoscalingPolicyRequest(kedaReq)
+ return &result
+}
diff --git a/utils/command_validator.go b/utils/command_validator.go
index aa45885c..8b4875b2 100644
--- a/utils/command_validator.go
+++ b/utils/command_validator.go
@@ -38,6 +38,7 @@ func getInput(actionType string) string {
if err != nil {
log.Errorf("Prompt failed %v", err)
os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
}
return result
diff --git a/utils/context.go b/utils/context.go
index 7982e8f3..9b896682 100644
--- a/utils/context.go
+++ b/utils/context.go
@@ -1,16 +1,21 @@
package utils
import (
+ context2 "context"
+ "encoding/base64"
"encoding/json"
"errors"
- "io/ioutil"
"os"
+ "strings"
"time"
- "github.com/dgrijalva/jwt-go"
+ "github.com/qovery/qovery-client-go"
+
+ "github.com/golang-jwt/jwt/v5"
)
const ContextFileName = "context"
+const ContextFilePermissions = 0600
type QoveryContext struct {
AccessToken AccessToken `json:"access_token"`
@@ -22,16 +27,44 @@ type QoveryContext struct {
ProjectName Name `json:"project_name"`
EnvironmentId Id `json:"environment_id"`
EnvironmentName Name `json:"environment_name"`
- ApplicationId Id `json:"application_id"`
- ApplicationName Name `json:"application_name"`
+ ServiceId Id `json:"service_id"`
+ ServiceName Name `json:"service_name"`
+ ServiceType ServiceType `json:"service_type"`
User Name `json:"user"`
}
type Name string
+type AccessTokenType string
type AccessToken string
type RefreshToken string
type Id string
-func CurrentContext() (QoveryContext, error) {
+func isMinimalContextValid(context QoveryContext) bool {
+ // this is the minimal context that we need to have to be able to use the CLI
+ return context.AccessToken != "" &&
+ context.AccessTokenExpiration.After(time.Now()) &&
+ context.RefreshToken != "" &&
+ context.OrganizationId != ""
+}
+
+func GetOrSetCurrentContext(setProject bool, setEnvironment bool, setService bool) (QoveryContext, error) {
+ context, _ := GetCurrentContext()
+ if isMinimalContextValid(context) &&
+ ((setProject && context.ProjectId != "") || !setProject) &&
+ ((setEnvironment && context.EnvironmentId != "") || !setEnvironment) &&
+ ((setService && context.ServiceId != "") || !setService) {
+ return context, nil
+ }
+
+ err := SetContext(setProject, setEnvironment, setService, false)
+
+ if err != nil {
+ return context, err
+ }
+
+ return GetCurrentContext()
+}
+
+func GetCurrentContext() (QoveryContext, error) {
context := QoveryContext{}
path, err := QoveryContextPath()
@@ -39,7 +72,7 @@ func CurrentContext() (QoveryContext, error) {
return context, err
}
- bytes, err := ioutil.ReadFile(path)
+ bytes, err := os.ReadFile(path)
if err != nil {
return context, err
}
@@ -52,13 +85,53 @@ func CurrentContext() (QoveryContext, error) {
return context, err
}
-func (c QoveryContext) ToPosthogProperties() map[string]interface{} {
- return map[string]interface{}{
- "organization": c.OrganizationName,
- "project": c.ProjectName,
- "environment": c.EnvironmentName,
- "application": c.ApplicationName,
+func SetContext(setProject bool, setEnvironment bool, setService bool, printFinalContext bool) error {
+ _ = PrintContext()
+ _ = ResetApplicationContext()
+
+ org, err := SelectAndSetOrganization()
+ if err != nil {
+ return err
+ }
+
+ if !setProject {
+ return nil
+ }
+
+ project, err := SelectAndSetProject(org.ID)
+ if err != nil {
+ return err
+ }
+
+ if !setEnvironment {
+ return nil
+ }
+
+ env, err := SelectAndSetEnvironment(project.ID)
+ if err != nil {
+ return err
+ }
+
+ if !setService {
+ return nil
+ }
+
+ _, err = SelectAndSetService(env.ID)
+ if err != nil {
+ return err
+ }
+ _, _ = CurrentService(false)
+
+ if printFinalContext {
+ println()
+ err = PrintContext()
+ if err != nil {
+ PrintlnError(err)
+ }
+ println()
}
+
+ return nil
}
func StoreContext(context QoveryContext) error {
@@ -72,59 +145,74 @@ func StoreContext(context QoveryContext) error {
return err
}
- return ioutil.WriteFile(path, bytes, os.ModePerm)
+ err = os.Chmod(path, ContextFilePermissions)
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(path, bytes, ContextFilePermissions)
}
-func CurrentOrganization() (Id, Name, error) {
- context, err := CurrentContext()
+func CurrentOrganization(promptContext bool) (Id, Name, error) {
+ context, err := GetCurrentContext()
+
+ if (err != nil || context.OrganizationId == "") && promptContext {
+ context, err = GetOrSetCurrentContext(false, false, false)
+ }
+
if err != nil {
return "", "", err
}
id := context.OrganizationId
if id == "" {
- return "", "", errors.New("Current organization has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
+ return "", "", errors.New("current organization has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
}
name := context.OrganizationName
if name == "" {
- return "", "", errors.New("Current organization has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
+ return "", "", errors.New("current organization has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
}
return id, name, nil
}
-func SetOrganization(orga *Organization) error {
- context, err := CurrentContext()
+func SetOrganization(org *Organization) error {
+ context, err := GetCurrentContext()
if err != nil {
return err
}
- context.OrganizationName = orga.Name
- context.OrganizationId = orga.ID
+ context.OrganizationName = org.Name
+ context.OrganizationId = org.ID
return StoreContext(context)
}
-func CurrentProject() (Id, Name, error) {
- context, err := CurrentContext()
+func CurrentProject(promptContext bool) (Id, Name, error) {
+ context, err := GetCurrentContext()
+
+ if (err != nil || context.ProjectId == "") && promptContext {
+ context, err = GetOrSetCurrentContext(true, false, false)
+ }
+
if err != nil {
return "", "", err
}
id := context.ProjectId
if id == "" {
- return "", "", errors.New("Current project has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
+ return "", "", errors.New("current project has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
}
name := context.ProjectName
if name == "" {
- return "", "", errors.New("Current project has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
+ return "", "", errors.New("current project has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
}
return id, name, nil
}
func SetProject(project *Project) error {
- context, err := CurrentContext()
+ context, err := GetCurrentContext()
if err != nil {
return err
}
@@ -135,26 +223,31 @@ func SetProject(project *Project) error {
return StoreContext(context)
}
-func CurrentEnvironment() (Id, Name, error) {
- context, err := CurrentContext()
+func CurrentEnvironment(promptContext bool) (Id, Name, error) {
+ context, err := GetCurrentContext()
+
+ if (err != nil || context.EnvironmentId == "") && promptContext {
+ context, err = GetOrSetCurrentContext(true, true, false)
+ }
+
if err != nil {
return "", "", err
}
id := context.EnvironmentId
if id == "" {
- return "", "", errors.New("Current environment has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
+ return "", "", errors.New("current environment has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
}
name := context.EnvironmentName
if name == "" {
- return "", "", errors.New("Current environment has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
+ return "", "", errors.New("current environment has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
}
return id, name, nil
}
func SetEnvironment(env *Environment) error {
- context, err := CurrentContext()
+ context, err := GetCurrentContext()
if err != nil {
return err
}
@@ -165,83 +258,125 @@ func SetEnvironment(env *Environment) error {
return StoreContext(context)
}
-func CurrentApplication() (Id, Name, error) {
- context, err := CurrentContext()
+func CurrentService(promptContext bool) (*Service, error) {
+ context, err := GetCurrentContext()
+
+ if (err != nil || context.ServiceId == "") && promptContext {
+ context, err = GetOrSetCurrentContext(true, true, true)
+ }
+
if err != nil {
- return "", "", err
+ return nil, err
}
- id := context.ApplicationId
+ id := context.ServiceId
if id == "" {
- return "", "", errors.New("Current application has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
+ return nil, errors.New("current service has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
}
- name := context.ApplicationName
+
+ name := context.ServiceName
if name == "" {
- return "", "", errors.New("Current application has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
+ return nil, errors.New("current service has not been selected. Please, use 'qovery context set' to set up Qovery context. ")
}
- return id, name, nil
+ return &Service{ID: id, Name: name, Type: context.ServiceType}, nil
}
-func SetApplication(application *Application) error {
- context, err := CurrentContext()
+func SetService(service *Service) error {
+ context, err := GetCurrentContext()
if err != nil {
return err
}
- context.ApplicationName = application.Name
- context.ApplicationId = application.ID
+ context.ServiceName = service.Name
+ context.ServiceId = service.ID
+ context.ServiceType = service.Type
return StoreContext(context)
}
-func GetAccessToken() (AccessToken, error) {
- context, err := CurrentContext()
+func GetAuthorizationHeaderValue(tokenType AccessTokenType, token AccessToken) string {
+ return string(tokenType) + " " + strings.TrimSpace(string(token))
+}
+
+func checkOrgaValid(orgaList *qovery.OrganizationResponseList) error {
+ if len(orgaList.GetResults()) == 0 {
+ return errors.New("you don't have any organization. Please create an account on https://start.qovery.com . ")
+ } else {
+ return nil
+ }
+}
+
+// GetAccessToken returns a valid access token, refreshing it if expired.
+// skipOrgaCheck should be false for every caller except the one legitimate
+// bootstrap case where an empty org list is expected: creating the user's
+// first organization via `qovery api organization --method POST ...`
+// (documented as a first-class example in `qovery api --help`). Passing
+// true anywhere else would let organization-scoped commands run with no
+// organization to scope to.
+func GetAccessToken(skipOrgaCheck bool) (AccessTokenType, AccessToken, error) {
+ apiToken := os.Getenv("QOVERY_CLI_ACCESS_TOKEN")
+ if apiToken == "" {
+ apiToken = os.Getenv("Q_CLI_ACCESS_TOKEN")
+ }
+ if apiToken != "" {
+ _, err := base64.StdEncoding.DecodeString(strings.Split(apiToken, ".")[0])
+ if err == nil {
+ return "Bearer", AccessToken(apiToken), nil
+ }
+ return "Token", AccessToken(apiToken), nil
+ }
+
+ // User does not use a Token, but a Jwt/Bearer token retrieve it from the context and check it has not expired
+ context, err := GetCurrentContext()
if err != nil {
- return AccessToken(""), err
+ return "", "", err
}
token := context.AccessToken
if token == "" {
- return "", errors.New("Access token has not been found. Please, sign in using 'qovery auth' command. ")
+ return "", "", errors.New("access token has not been found. Sign in using 'qovery auth' or 'qovery auth --headless' command. ")
}
- expired := context.AccessTokenExpiration.Before(time.Now())
- if expired {
- RefreshExpiredTokenSilently()
- refreshed, err := GetAccessToken()
- if err != nil {
- return AccessToken(""), err
+ // check the token is valid by trying to list the organizations
+ if orgaList, _, err := GetQoveryClient("Bearer", token).OrganizationMainCallsAPI.ListOrganization(context2.Background()).Execute(); err == nil {
+ // everything is fine, return the token
+ if !skipOrgaCheck {
+ if err = checkOrgaValid(orgaList); err != nil {
+ return "", "", err
+ }
}
- token = refreshed
+ return "Bearer", token, nil
}
- return token, nil
-}
-
-func GetAccessTokenExpiration() (time.Time, error) {
- context, err := CurrentContext()
- t := time.Time{}
- if err != nil {
- return t, err
+ // Means the token is expired or invalid. Try to refresh it
+ if token, err = RefreshAccessToken(context.RefreshToken); err != nil {
+ return "", "", err
}
- expiration := context.AccessTokenExpiration
- if expiration == t {
- return t, errors.New("Access token has not been found. Please, sign in using 'qovery auth' command. ")
+ if orgaList, _, err := GetQoveryClient("Bearer", token).OrganizationMainCallsAPI.ListOrganization(context2.Background()).Execute(); err == nil {
+ // everything is fine, return the token
+ if !skipOrgaCheck {
+ if err = checkOrgaValid(orgaList); err != nil {
+ return "", "", err
+ }
+ }
+
+ return "Bearer", token, nil
}
- return expiration, nil
+ return "", "", errors.New("access token is invalid or expired. Sign in using 'qovery auth' or 'qovery auth --headless' command. ")
}
-func SetAccessToken(token AccessToken, expiration time.Time) error {
- context, err := CurrentContext()
+func SetAccessToken(token AccessToken, expiration time.Time, refreshToken RefreshToken) error {
+ context, err := GetCurrentContext()
if err != nil {
return err
}
context.AccessToken = token
context.AccessTokenExpiration = expiration
+ context.RefreshToken = refreshToken
claims := jwt.MapClaims{}
_, _ = jwt.ParseWithClaims(string(token), claims, func(token *jwt.Token) (interface{}, error) {
@@ -257,31 +392,6 @@ func SetAccessToken(token AccessToken, expiration time.Time) error {
return StoreContext(context)
}
-func GetRefreshToken() (RefreshToken, error) {
- context, err := CurrentContext()
- if err != nil {
- return RefreshToken(""), err
- }
-
- token := context.RefreshToken
- if token == "" {
- return "", errors.New("Refresh token has not been found. Please, sign in using 'qovery auth' command. ")
- }
-
- return token, nil
-}
-
-func SetRefreshToken(token RefreshToken) error {
- context, err := CurrentContext()
- if err != nil {
- return err
- }
-
- context.RefreshToken = token
-
- return StoreContext(context)
-}
-
func InitializeQoveryContext() error {
if !QoveryDirExists() {
path, err := QoveryDirPath()
@@ -305,7 +415,12 @@ func InitializeQoveryContext() error {
return err
}
- err = ioutil.WriteFile(path, []byte("{}"), os.ModePerm)
+ err = os.Chmod(path, ContextFilePermissions)
+ if err != nil {
+ return err
+ }
+
+ err = os.WriteFile(path, []byte("{}"), ContextFilePermissions)
if err != nil {
return err
}
diff --git a/utils/context_test.go b/utils/context_test.go
new file mode 100644
index 00000000..ecacf4bc
--- /dev/null
+++ b/utils/context_test.go
@@ -0,0 +1,99 @@
+package utils
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/qovery/qovery-client-go"
+)
+
+func TestCheckOrgaValid(t *testing.T) {
+ tests := []struct {
+ name string
+ results []qovery.Organization
+ wantErr bool
+ }{
+ {"empty organization list returns an error", []qovery.Organization{}, true},
+ {"non-empty organization list returns nil", []qovery.Organization{{Id: "org-1", Name: "test"}}, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ list := qovery.OrganizationResponseList{Results: tt.results}
+ err := checkOrgaValid(&list)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("checkOrgaValid() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+// writeTestQoveryContext writes a minimal, valid ~/.qovery/context.json under home
+// so GetCurrentContext() finds a non-expired access token without touching the
+// real user's context.
+func writeTestQoveryContext(t *testing.T, home string) {
+ t.Helper()
+ dir := filepath.Join(home, ".qovery")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ ctx := QoveryContext{
+ AccessToken: "test-access-token",
+ AccessTokenExpiration: time.Now().Add(time.Hour),
+ RefreshToken: "test-refresh-token",
+ }
+ bytes, err := json.Marshal(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ contextPath := filepath.Join(dir, ContextFileName+".json")
+ if err := os.WriteFile(contextPath, bytes, ContextFilePermissions); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestGetAccessToken_SkipOrgaCheck locks in the one behavior the qovery-cli#702
+// review asked to have covered: GetAccessToken(false) must keep rejecting a
+// zero-organization account (the guard every other command relies on), while
+// GetAccessToken(true) must let that one case through â used exclusively by
+// `qovery api organization --method POST` to bootstrap a brand-new account's
+// first organization.
+func TestGetAccessToken_SkipOrgaCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ orgListJSON string
+ skipOrgaCheck bool
+ wantErr bool
+ }{
+ {"zero organizations, check enforced -> rejected", `{"results":[]}`, false, true},
+ {"zero organizations, check skipped -> allowed", `{"results":[]}`, true, false},
+ {"existing organization, check enforced -> allowed", `{"results":[{"id":"org-1","created_at":"2024-01-01T00:00:00Z","name":"test","plan":"BUSINESS_2025"}]}`, false, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(tt.orgListJSON))
+ }))
+ defer server.Close()
+
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("QOVERY_API_URL", server.URL)
+ t.Setenv("QOVERY_CLI_ACCESS_TOKEN", "")
+ t.Setenv("Q_CLI_ACCESS_TOKEN", "")
+ writeTestQoveryContext(t, home)
+
+ _, _, err := GetAccessToken(tt.skipOrgaCheck)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("GetAccessToken(%v) error = %v, wantErr %v", tt.skipOrgaCheck, err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/utils/date.go b/utils/date.go
new file mode 100644
index 00000000..f4105685
--- /dev/null
+++ b/utils/date.go
@@ -0,0 +1,12 @@
+package utils
+
+import "time"
+
+func ToIso8601(v *time.Time) *string {
+ if v == nil {
+ return nil
+ }
+
+ x := v.Format("2006-01-02T15:04:05.000Z")
+ return &x
+}
diff --git a/utils/env_var.go b/utils/env_var.go
new file mode 100644
index 00000000..d88d9811
--- /dev/null
+++ b/utils/env_var.go
@@ -0,0 +1,905 @@
+package utils
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/pterm/pterm"
+ "github.com/qovery/qovery-client-go"
+ "os"
+ "sort"
+ "strings"
+ "time"
+)
+
+var ShowValues bool
+var PrettyPrint bool
+var SortKeys bool
+var IsSecret bool
+var ApplicationScope string
+var JobScope string
+var ContainerScope string
+var HelmScope string
+var EnvironmentScope string
+var Alias string
+var Key string
+var Value string
+var SecretManagerAccessName string
+var Reference string
+var MountPath string
+var TerraformScope string
+
+type EnvVarLines struct {
+ lines map[string][]EnvVarLineOutput
+}
+
+type Var struct {
+ Key string
+ Value string
+}
+
+func NewEnvVarLines() EnvVarLines {
+ return EnvVarLines{
+ lines: make(map[string][]EnvVarLineOutput),
+ }
+}
+func (e EnvVarLines) Add(env EnvVarLineOutput) {
+ var parentKey *string
+
+ if env.AliasParentKey != nil {
+ parentKey = env.AliasParentKey
+ } else if env.OverrideParentKey != nil {
+ parentKey = env.OverrideParentKey
+ }
+
+ if parentKey != nil {
+ e.lines[*parentKey] = append(e.lines[*parentKey], env)
+ return
+ }
+
+ e.lines[env.Key] = []EnvVarLineOutput{env}
+}
+
+func (e EnvVarLines) Header(prettyPrint bool) []string {
+ if prettyPrint {
+ return []string{"Key", "Type", "Value", "Updated at", "Service", "Scope"}
+ }
+
+ return []string{"Key", "Type", "Parent Key", "Value", "Updated at", "Service", "Scope"}
+}
+
+func (e EnvVarLines) Lines(showValues bool, prettyPrint bool, sortKeys bool) [][]string {
+ var lines [][]string
+
+ // Get keys and optionally sort them
+ keys := make([]string, 0, len(e.lines))
+ for key := range e.lines {
+ keys = append(keys, key)
+ }
+ if sortKeys {
+ sort.Strings(keys)
+ }
+
+ // Iterate over sorted keys instead of map
+ for _, key := range keys {
+ envVars := e.lines[key]
+ for idx, envVar := range envVars {
+ x := envVar.Data(showValues)
+ if idx == 0 || !prettyPrint {
+ if prettyPrint {
+ lines = append(lines, []string{x[0], x[1], x[3], x[4], x[5], x[6]})
+ } else {
+ lines = append(lines, x)
+ }
+ } else {
+ x[0] = "âââ " + x[0]
+ // remove Parent Key value
+ lines = append(lines, []string{x[0], x[1], x[3], x[4], x[5], x[6]})
+ }
+ }
+ }
+
+ return lines
+}
+
+type EnvVarLineOutput struct {
+ Id string
+ Key string
+ Value *string
+ CreatedAt time.Time
+ UpdatedAt *time.Time
+ Service *string
+ Scope string
+ IsSecret bool
+ AliasParentKey *string
+ OverrideParentKey *string
+}
+
+func (e EnvVarLineOutput) Data(showValues bool) []string {
+ service := "N/A"
+ if e.Service != nil {
+ service = *e.Service
+ }
+
+ value := "********"
+ if showValues && e.Value != nil && !e.IsSecret {
+ value = *e.Value
+ }
+
+ keyType := "Variable"
+ if e.IsSecret {
+ keyType = "Secret"
+ }
+
+ parentKey := "N/A"
+ if e.AliasParentKey != nil {
+ parentKey = *e.AliasParentKey
+ keyType = keyType + " Alias"
+ }
+
+ if e.OverrideParentKey != nil {
+ parentKey = *e.OverrideParentKey
+ keyType = keyType + " Override"
+ }
+
+ return []string{e.Key, keyType, parentKey, value, e.UpdatedAt.Format(time.RFC822), service, e.Scope}
+}
+
+func FromEnvironmentVariableToEnvVarLineOutput(envVar qovery.VariableResponse) EnvVarLineOutput {
+ var aliasParentKey *string
+ if envVar.AliasedVariable != nil {
+ aliasParentKey = &envVar.AliasedVariable.Key
+ }
+
+ var overrideParentKey *string
+ if envVar.OverriddenVariable != nil {
+ overrideParentKey = &envVar.OverriddenVariable.Key
+ }
+
+ var value *string
+ if envVar.Value.IsSet() {
+ value = envVar.Value.Get()
+ }
+
+ return EnvVarLineOutput{
+ Id: envVar.Id,
+ Key: envVar.Key,
+ Value: value,
+ CreatedAt: envVar.CreatedAt,
+ UpdatedAt: envVar.UpdatedAt,
+ Service: envVar.ServiceName,
+ Scope: string(envVar.Scope),
+ IsSecret: envVar.IsSecret,
+ AliasParentKey: aliasParentKey,
+ OverrideParentKey: overrideParentKey,
+ }
+}
+
+func CreateServiceVariable(
+ client *qovery.APIClient,
+ projectId string,
+ environmentId string,
+ serviceId string,
+ scope string,
+ key string,
+ value string,
+ isSecret bool,
+) error {
+
+ parentId, parentScope, err := getParentIdByScope(scope, projectId, environmentId, serviceId)
+ if err != nil {
+ return err
+ }
+
+ variableRequest := qovery.VariableRequest{
+ Key: key,
+ Value: value,
+ MountPath: qovery.NullableString{},
+ IsSecret: isSecret,
+ VariableScope: parentScope,
+ VariableParentId: parentId,
+ }
+
+ _, _, err = client.VariableMainCallsAPI.CreateVariable(context.Background()).VariableRequest(variableRequest).Execute()
+ return err
+}
+
+func CreateServiceExternalSecret(
+ client *qovery.APIClient,
+ projectId string,
+ environmentId string,
+ serviceId string,
+ scope string,
+ key string,
+ reference string,
+ secretManagerAccessId string,
+ mountPath string,
+) error {
+ parentId, parentScope, err := getParentIdByScope(scope, projectId, environmentId, serviceId)
+ if err != nil {
+ return err
+ }
+
+ variableRequest := qovery.VariableRequest{
+ Key: key,
+ Value: reference,
+ IsSecret: false,
+ VariableScope: parentScope,
+ VariableParentId: parentId,
+ }
+ variableRequest.SetSecretManagerAccessId(secretManagerAccessId)
+ if mountPath != "" {
+ variableRequest.SetMountPath(mountPath)
+ }
+
+ _, _, err = client.VariableMainCallsAPI.CreateVariable(context.Background()).VariableRequest(variableRequest).Execute()
+ return err
+}
+
+func UpdateServiceExternalSecret(
+ client *qovery.APIClient,
+ key string,
+ reference string,
+ secretManagerAccessId string,
+ serviceId string,
+ serviceType ServiceType,
+) error {
+ envVars, err := ListServiceVariables(client, serviceId, serviceType)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+ if envVar == nil {
+ return fmt.Errorf("external secret %s not found", pterm.FgRed.Sprintf("%s", key))
+ }
+
+ editRequest := qovery.VariableEditRequest{
+ Key: key,
+ }
+ if reference != "" {
+ editRequest.SetValue(reference)
+ }
+ if secretManagerAccessId != "" {
+ editRequest.SetSecretManagerAccessId(secretManagerAccessId)
+ }
+
+ _, _, err = client.VariableMainCallsAPI.EditVariable(context.Background(), envVar.Id).VariableEditRequest(editRequest).Execute()
+ return err
+}
+
+func UpdateEnvironmentExternalSecret(
+ client *qovery.APIClient,
+ environmentId string,
+ key string,
+ reference string,
+ secretManagerAccessId string,
+) error {
+ envVars, err := ListEnvironmentVariables(client, environmentId)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+ if envVar == nil {
+ return fmt.Errorf("external secret %s not found", pterm.FgRed.Sprintf("%s", key))
+ }
+
+ editRequest := qovery.VariableEditRequest{
+ Key: key,
+ }
+ if reference != "" {
+ editRequest.SetValue(reference)
+ }
+ if secretManagerAccessId != "" {
+ editRequest.SetSecretManagerAccessId(secretManagerAccessId)
+ }
+
+ _, _, err = client.VariableMainCallsAPI.EditVariable(context.Background(), envVar.Id).VariableEditRequest(editRequest).Execute()
+ return err
+}
+
+func CreateEnvironmentVariable(
+ client *qovery.APIClient,
+ projectId string,
+ environmentId string,
+ key string,
+ value string,
+ isSecret bool,
+) error {
+ variableRequest := qovery.VariableRequest{
+ Key: key,
+ Value: value,
+ MountPath: qovery.NullableString{},
+ IsSecret: isSecret,
+ VariableScope: qovery.APIVARIABLESCOPEENUM_ENVIRONMENT,
+ VariableParentId: environmentId,
+ }
+
+ _, _, err := client.VariableMainCallsAPI.CreateVariable(context.Background()).VariableRequest(variableRequest).Execute()
+ return err
+}
+
+func CreateProjectVariable(
+ client *qovery.APIClient,
+ projectId string,
+ key string,
+ value string,
+ isSecret bool,
+) error {
+ variableRequest := qovery.VariableRequest{
+ Key: key,
+ Value: value,
+ MountPath: qovery.NullableString{},
+ IsSecret: isSecret,
+ VariableScope: qovery.APIVARIABLESCOPEENUM_PROJECT,
+ VariableParentId: projectId,
+ }
+
+ _, _, err := client.VariableMainCallsAPI.CreateVariable(context.Background()).VariableRequest(variableRequest).Execute()
+ return err
+}
+
+func UpdateServiceVariable(
+ client *qovery.APIClient,
+ key string,
+ value string,
+ serviceId string,
+ serviceType ServiceType,
+) error {
+ envVars, err := ListServiceVariables(client, serviceId, serviceType)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+ if envVar == nil {
+ errorKey := pterm.FgRed.Sprintf("%s", key)
+ return fmt.Errorf("environment variable %s not found", errorKey)
+ }
+
+ nullableValue := qovery.NullableString{}
+ nullableValue.Set(&value)
+
+ // fmt.Printf(envVar.Id)
+ variableId := envVar.Id
+ variableEditRequest := qovery.VariableEditRequest{
+ Key: key,
+ Value: nullableValue,
+ }
+
+ _, _, err = client.VariableMainCallsAPI.EditVariable(context.Background(), variableId).VariableEditRequest(variableEditRequest).Execute()
+ return err
+}
+
+func UpdateEnvironmentVariable(
+ client *qovery.APIClient,
+ environmentId string,
+ key string,
+ value string,
+) error {
+ envVars, err := ListEnvironmentVariables(client, environmentId)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+ if envVar == nil {
+ errorKey := pterm.FgRed.Sprintf("%s", key)
+ return fmt.Errorf("environment variable %s not found", errorKey)
+ }
+
+ nullableValue := qovery.NullableString{}
+ nullableValue.Set(&value)
+
+ variableId := envVar.Id
+ variableEditRequest := qovery.VariableEditRequest{
+ Key: key,
+ Value: nullableValue,
+ }
+
+ _, _, err = client.VariableMainCallsAPI.EditVariable(context.Background(), variableId).VariableEditRequest(variableEditRequest).Execute()
+ return err
+}
+
+func UpdateProjectVariable(
+ client *qovery.APIClient,
+ projectId string,
+ key string,
+ value string,
+) error {
+ envVars, err := ListProjectVariables(client, projectId)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+ if envVar == nil {
+ errorKey := pterm.FgRed.Sprintf("%s", key)
+ return fmt.Errorf("project variable %s not found", errorKey)
+ }
+
+ nullableValue := qovery.NullableString{}
+ nullableValue.Set(&value)
+
+ variableId := envVar.Id
+ variableEditRequest := qovery.VariableEditRequest{
+ Key: key,
+ Value: nullableValue,
+ }
+
+ _, _, err = client.VariableMainCallsAPI.EditVariable(context.Background(), variableId).VariableEditRequest(variableEditRequest).Execute()
+ return err
+}
+
+func FindEnvironmentVariableByKey(key string, envVars []qovery.VariableResponse) *qovery.VariableResponse {
+ for _, envVar := range envVars {
+ if envVar.Key == key {
+ return &envVar
+ }
+ }
+
+ return nil
+}
+
+func ListServiceVariables(
+ client *qovery.APIClient,
+ serviceId string,
+ serviceType ServiceType,
+) ([]qovery.VariableResponse, error) {
+ scope, err := ServiceTypeToScope(serviceType)
+ if err != nil {
+ return nil, err
+ }
+
+ request := client.VariableMainCallsAPI.ListVariables(context.Background())
+ res, _, err := request.ParentId(serviceId).Scope(scope).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ if res == nil {
+ return nil, errors.New("invalid service type")
+ }
+
+ return res.GetResults(), nil
+}
+
+func ListEnvironmentVariables(
+ client *qovery.APIClient,
+ environmentId string,
+) ([]qovery.VariableResponse, error) {
+ request := client.VariableMainCallsAPI.ListVariables(context.Background())
+ res, _, err := request.ParentId(environmentId).Scope(qovery.APIVARIABLESCOPEENUM_ENVIRONMENT).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ if res == nil {
+ return nil, errors.New("invalid environment")
+ }
+
+ return res.GetResults(), nil
+}
+
+func ListProjectVariables(
+ client *qovery.APIClient,
+ projectId string,
+) ([]qovery.VariableResponse, error) {
+ request := client.VariableMainCallsAPI.ListVariables(context.Background())
+ res, _, err := request.ParentId(projectId).Scope(qovery.APIVARIABLESCOPEENUM_PROJECT).Execute()
+ if err != nil {
+ return nil, err
+ }
+
+ if res == nil {
+ return nil, errors.New("invalid project")
+ }
+
+ return res.GetResults(), nil
+}
+
+func ServiceTypeToScope(serviceType ServiceType) (qovery.APIVariableScopeEnum, error) {
+ switch serviceType {
+ case ApplicationType:
+ return qovery.APIVARIABLESCOPEENUM_APPLICATION, nil
+ case ContainerType:
+ return qovery.APIVARIABLESCOPEENUM_CONTAINER, nil
+ case JobType:
+ return qovery.APIVARIABLESCOPEENUM_JOB, nil
+ case HelmType:
+ return qovery.APIVARIABLESCOPEENUM_HELM, nil
+ case TerraformType:
+ return qovery.APIVARIABLESCOPEENUM_TERRAFORM, nil
+ }
+
+ return qovery.APIVARIABLESCOPEENUM_BUILT_IN, fmt.Errorf("the service type %s is not supported", serviceType)
+}
+
+func getParentIdByScope(scope string, projectId string, environmentId string, serviceId string) (string, qovery.APIVariableScopeEnum, error) {
+ switch scope {
+ case "PROJECT":
+ return projectId, qovery.APIVARIABLESCOPEENUM_PROJECT, nil
+ case "ENVIRONMENT":
+ return environmentId, qovery.APIVARIABLESCOPEENUM_ENVIRONMENT, nil
+ case "APPLICATION":
+ return serviceId, qovery.APIVARIABLESCOPEENUM_APPLICATION, nil
+ case "CONTAINER":
+ return serviceId, qovery.APIVARIABLESCOPEENUM_CONTAINER, nil
+ case "JOB":
+ return serviceId, qovery.APIVARIABLESCOPEENUM_JOB, nil
+ case "HELM":
+ return serviceId, qovery.APIVARIABLESCOPEENUM_HELM, nil
+ case "TERRAFORM":
+ return serviceId, qovery.APIVARIABLESCOPEENUM_TERRAFORM, nil
+ }
+
+ return "", qovery.APIVARIABLESCOPEENUM_BUILT_IN, fmt.Errorf("scope %s not supported", scope)
+}
+
+func DeleteServiceVariable(client *qovery.APIClient, serviceId string, serviceType ServiceType, key string) error {
+ envVars, err := ListServiceVariables(client, serviceId, serviceType)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+ if envVar == nil {
+ return fmt.Errorf("environment variable %s not found", pterm.FgRed.Sprintf("%s", key))
+ }
+
+ _, err = client.VariableMainCallsAPI.DeleteVariable(context.Background(), envVar.Id).Execute()
+ return err
+}
+
+func DeleteEnvironmentVar(client *qovery.APIClient, environmentId string, key string) error {
+ envVars, err := ListEnvironmentVariables(client, environmentId)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+ if envVar == nil {
+ return fmt.Errorf("environment variable %s not found", pterm.FgRed.Sprintf("%s", key))
+ }
+
+ _, err = client.VariableMainCallsAPI.DeleteVariable(context.Background(), envVar.Id).Execute()
+ return err
+}
+
+func DeleteProjectVar(client *qovery.APIClient, projectId string, key string) error {
+ envVars, err := ListProjectVariables(client, projectId)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+ if envVar == nil {
+ return fmt.Errorf("environment variable %s not found", pterm.FgRed.Sprintf("%s", key))
+ }
+
+ _, err = client.VariableMainCallsAPI.DeleteVariable(context.Background(), envVar.Id).Execute()
+ return err
+}
+
+func CreateEnvironmentVariableAlias(
+ client *qovery.APIClient,
+ aliasParentId string,
+ aliasScope qovery.APIVariableScopeEnum,
+ variableId string,
+ alias string,
+) error {
+ variableAliasRequest := qovery.VariableAliasRequest{
+ Key: alias,
+ AliasScope: aliasScope,
+ AliasParentId: aliasParentId,
+ }
+
+ _, _, err := client.VariableMainCallsAPI.CreateVariableAlias(context.Background(), variableId).VariableAliasRequest(variableAliasRequest).Execute()
+ return err
+}
+
+func CreateServiceAlias(
+ client *qovery.APIClient,
+ projectId string,
+ environmentId string,
+ serviceId string,
+ serviceType ServiceType,
+ key string,
+ alias string,
+ scope string,
+) error {
+ envVars, err := ListServiceVariables(client, serviceId, serviceType)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+
+ parentId, parentScope, err := getParentIdByScope(scope, projectId, environmentId, serviceId)
+ if err != nil {
+ return err
+ }
+
+ if envVar != nil {
+ // create alias for environment variable
+ return CreateEnvironmentVariableAlias(client, parentId, parentScope, envVar.Id, alias)
+ }
+
+ return fmt.Errorf("Environment variable or secret %s not found", pterm.FgRed.Sprintf("%s", key))
+}
+
+func CreateEnvironmentAlias(
+ client *qovery.APIClient,
+ projectId string,
+ environmentId string,
+ key string,
+ alias string,
+ scope string,
+) error {
+ envVars, err := ListEnvironmentVariables(client, environmentId)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+
+ parentId, parentScope, err := getParentIdByScope(scope, projectId, environmentId, "")
+ if err != nil {
+ return err
+ }
+
+ if envVar != nil {
+ // create alias for environment variable
+ return CreateEnvironmentVariableAlias(client, parentId, parentScope, envVar.Id, alias)
+ }
+
+ return fmt.Errorf("Environment variable or secret %s not found", pterm.FgRed.Sprintf("%s", key))
+}
+
+func CreateProjectAlias(
+ client *qovery.APIClient,
+ projectId string,
+ key string,
+ alias string,
+) error {
+ envVars, err := ListProjectVariables(client, projectId)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+
+ parentId, parentScope, err := getParentIdByScope("PROJECT", projectId, "", "")
+ if err != nil {
+ return err
+ }
+
+ if envVar != nil {
+ // create alias for environment variable
+ return CreateEnvironmentVariableAlias(client, parentId, parentScope, envVar.Id, alias)
+ }
+
+ return fmt.Errorf("Project variable or secret %s not found", pterm.FgRed.Sprintf("%s", key))
+}
+
+func CreateEnvironmentVariableOverride(
+ client *qovery.APIClient,
+ overrideParentId string,
+ overrideScope qovery.APIVariableScopeEnum,
+ variableId string,
+ value string,
+) error {
+ variableOverrideRequest := qovery.VariableOverrideRequest{
+ Value: value,
+ OverrideScope: overrideScope,
+ OverrideParentId: overrideParentId,
+ }
+
+ _, _, err := client.VariableMainCallsAPI.CreateVariableOverride(context.Background(), variableId).VariableOverrideRequest(variableOverrideRequest).Execute()
+ return err
+}
+
+func CreateServiceOverride(
+ client *qovery.APIClient,
+ projectId string,
+ environmentId string,
+ serviceId string,
+ serviceType ServiceType,
+ key string,
+ value string,
+ scope string,
+) error {
+ envVars, err := ListServiceVariables(client, serviceId, serviceType)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+
+ parentId, parentScope, err := getParentIdByScope(scope, projectId, environmentId, serviceId)
+ if err != nil {
+ return err
+ }
+
+ if envVar != nil {
+ // create override for environment variable
+ return CreateEnvironmentVariableOverride(client, parentId, parentScope, envVar.Id, value)
+ }
+
+ return fmt.Errorf("Environment variable or secret %s not found", pterm.FgRed.Sprintf("%s", key))
+}
+
+func CreateEnvironmentOverride(
+ client *qovery.APIClient,
+ projectId string,
+ environmentId string,
+ key string,
+ value string,
+ scope string,
+) error {
+ envVars, err := ListEnvironmentVariables(client, environmentId)
+ if err != nil {
+ return err
+ }
+
+ envVar := FindEnvironmentVariableByKey(key, envVars)
+
+ parentId, parentScope, err := getParentIdByScope(scope, projectId, environmentId, "")
+ if err != nil {
+ return err
+ }
+
+ if envVar != nil {
+ // create override for environment variable
+ return CreateEnvironmentVariableOverride(client, parentId, parentScope, envVar.Id, value)
+ }
+
+ return fmt.Errorf("Environment variable or secret %s not found", pterm.FgRed.Sprintf("%s", key))
+}
+
+func insertAtIndex(src string, insert string, index int) string {
+ // Convert to rune slice if you expect to be working with Unicode
+ srcRunes := []rune(src)
+
+ // Handle index out of range cases
+ if index < 0 || index > len(srcRunes) {
+ return src
+ }
+
+ // Create a new rune slice that consists of the original string
+ // with the new string inserted at the index
+ newRunes := make([]rune, len(srcRunes)+len([]rune(insert)))
+ copy(newRunes, srcRunes[:index])
+ copy(newRunes[index:], []rune(insert))
+ copy(newRunes[index+len([]rune(insert)):], srcRunes[index:])
+
+ // Convert the rune slice back to a string and return it
+ return string(newRunes)
+}
+
+func getInterpolatedValue(value *string, variables []EnvVarLineOutput, aliasParentKey *string) *string {
+ if value == nil {
+ return nil
+ }
+
+ if aliasParentKey != nil {
+ for _, x := range variables {
+ if *aliasParentKey == x.Key {
+ return x.Value
+ }
+ }
+ }
+
+ if !strings.Contains(*value, "{{") {
+ return value
+ }
+
+ runes := []rune(*value)
+
+ startIndex := -1
+ endIndex := -1
+
+ // let's found the startIndex and endIndex with "hello_${world}" -> startIndex = 6, endIndex = 11
+ foundFirstFirstDelimiter := false
+ foundFirstLastDelimiter := false
+ for idx, char := range runes {
+ if char == '{' && !foundFirstFirstDelimiter {
+ foundFirstFirstDelimiter = true
+ } else if char == '{' {
+ startIndex = idx - 1 // 2 chars -> {{
+ } else if startIndex > -1 && char == '}' && !foundFirstLastDelimiter {
+ foundFirstLastDelimiter = true
+ } else if startIndex > -1 && char == '}' {
+ endIndex = idx
+ break // we can stop here and interpolate the value
+ }
+ }
+
+ if startIndex == -1 || endIndex == -1 {
+ return value
+ }
+
+ // extract key from {{key}}
+ keyToInterpolate := string(runes[startIndex+2 : endIndex-1])
+
+ // remove ${{key}} from value
+ valueWithoutInterpolation := string(runes[:startIndex]) + string(runes[endIndex+1:])
+
+ finalValue := *value
+
+FirstLoop:
+ for _, v := range variables {
+ if v.Key == keyToInterpolate {
+ if v.AliasParentKey != nil {
+ // where v is an Alias, we should interpolate the value of the parent key
+ for _, x := range variables {
+ if v.AliasParentKey != nil && *v.AliasParentKey == x.Key {
+ finalValue = insertAtIndex(valueWithoutInterpolation, getValueOrDefault(x.Value), startIndex)
+ continue FirstLoop
+ }
+ }
+ }
+
+ // work only if the key is a secret or an environment variable
+ finalValue = insertAtIndex(valueWithoutInterpolation, getValueOrDefault(v.Value), startIndex)
+ break
+ }
+ }
+
+ if strings.Contains(finalValue, "{{") && finalValue != *value {
+ return getInterpolatedValue(&finalValue, variables, nil)
+ }
+
+ return &finalValue
+}
+
+func getValueOrDefault(value *string) string {
+ if value == nil {
+ return "xxx secret xxx"
+ } else {
+ return *value
+ }
+}
+
+func GetEnvVarJsonOutput(variables []EnvVarLineOutput, sortKeys bool) string {
+ var results []interface{}
+
+ // Optionally sort variables by key before processing
+ if sortKeys {
+ sortedVars := make([]EnvVarLineOutput, len(variables))
+ copy(sortedVars, variables)
+ sort.Slice(sortedVars, func(i, j int) bool {
+ return sortedVars[i].Key < sortedVars[j].Key
+ })
+ variables = sortedVars
+ }
+
+ for _, v := range variables {
+ // TODO improve this
+
+ results = append(results, map[string]interface{}{
+ "id": v.Id,
+ "created_at": ToIso8601(&v.CreatedAt),
+ "updated_at": ToIso8601(v.UpdatedAt),
+ "key": v.Key,
+ "value": v.Value,
+ "interpolated_value": getInterpolatedValue(v.Value, variables, v.AliasParentKey),
+ "service_name": v.Service,
+ "scope": v.Scope,
+ "alias_parent_key": v.AliasParentKey,
+ "override_parent_value": v.OverrideParentKey,
+ })
+ }
+
+ j, err := json.Marshal(results)
+
+ if err != nil {
+ PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return string(j)
+}
diff --git a/utils/error.go b/utils/error.go
new file mode 100644
index 00000000..831afbcc
--- /dev/null
+++ b/utils/error.go
@@ -0,0 +1,14 @@
+package utils
+
+import (
+ "fmt"
+)
+
+type HttpResponseError struct {
+ Code int
+ Message string
+}
+
+func (m *HttpResponseError) Error() string {
+ return fmt.Sprintf("\nHTTP Response Code: %d\nError Message: %s", m.Code, m.Message)
+}
diff --git a/utils/file_handler.go b/utils/file_handler.go
index 01210e5b..c7e98e2a 100644
--- a/utils/file_handler.go
+++ b/utils/file_handler.go
@@ -1,19 +1,24 @@
package utils
import (
- log "github.com/sirupsen/logrus"
"os"
"runtime"
+
+ log "github.com/sirupsen/logrus"
)
func WriteInFile(clusterId string, fileName string, content []byte) string {
fullPath := GetFullPath(clusterId)
- err := os.Mkdir(fullPath, 0777)
- if err != nil {
- log.Error("Couldn't create folder : " + err.Error())
+ if _, err := os.Stat(fullPath); os.IsNotExist(err) {
+ err := os.Mkdir(fullPath, 0777)
+ if err != nil {
+ log.Error("Couldn't create folder : " + err.Error())
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
}
- err = os.WriteFile(fullPath+fileName, content, 0777)
+ err := os.WriteFile(fullPath+fileName, content, 0777)
if err != nil {
log.Error("Couldn't write file : " + err.Error())
return ""
diff --git a/utils/map.go b/utils/map.go
new file mode 100644
index 00000000..37b9d7e6
--- /dev/null
+++ b/utils/map.go
@@ -0,0 +1,10 @@
+package utils
+
+// Map applies a transformer function to each element in the slice
+func Map[T, U any](slice []T, transformer func(T) U) []U {
+ result := make([]U, len(slice))
+ for i, item := range slice {
+ result[i] = transformer(item)
+ }
+ return result
+}
diff --git a/utils/posthog.go b/utils/posthog.go
index d6e681f8..da68e25b 100644
--- a/utils/posthog.go
+++ b/utils/posthog.go
@@ -1,6 +1,8 @@
package utils
import (
+ "os"
+ "runtime"
"strings"
"time"
@@ -9,25 +11,71 @@ import (
"github.com/spf13/pflag"
)
+const DefaultEventName = "cli-command-execution"
+const EndOfExecutionEventName = "cli-command-execution-end"
+const EndOfExecutionErrorEventName = "cli-command-execution-error"
+
func Capture(command *cobra.Command) {
+ CaptureWithEvent(command, DefaultEventName)
+}
+
+func CaptureError(command *cobra.Command, stdout string, stderr string) {
+ properties := posthog.Properties{
+ "stdout": stdout,
+ "stderr": stderr,
+ }
+ CaptureWithEventAndProperties(command, EndOfExecutionErrorEventName, properties)
+}
+
+func CaptureWithEvent(command *cobra.Command, event string) {
+ CaptureWithEventAndProperties(command, event, posthog.Properties{})
+}
+
+func CaptureWithEventAndProperties(command *cobra.Command, event string, properties posthog.Properties) {
+ // Apply the telemetry opt-out to every event, including failures and completion.
+ if strings.EqualFold(os.Getenv("QOVERY_TELEMETRY"), "false") {
+ return
+ }
+
ph, err := posthog.NewWithConfig(
"phc_IgdG1K2GveDUte1gJ6hlwNbFHCv9nViWETUyLMU7ciq",
posthog.Config{
- Endpoint: "https://app.posthog.com",
+ Endpoint: "https://e.qovery.com",
},
)
+
if err != nil {
return
}
- defer ph.Close()
- ctx, err := CurrentContext()
+ defer func() {
+ _ = ph.Close()
+ }()
+
+ ctx, err := GetCurrentContext()
if err != nil {
return
}
- properties := ctx.ToPosthogProperties()
- properties["command"] = commandName(command)
+ tokenType := "jwt"
+ if strings.HasPrefix(string(ctx.AccessToken), "qov_") {
+ tokenType = "static"
+ }
+
+ mProperties := properties.
+ Set("organization", ctx.OrganizationName).
+ Set("organization_id", ctx.OrganizationId).
+ Set("project", ctx.ProjectName).
+ Set("project_id", ctx.ProjectId).
+ Set("environment", ctx.EnvironmentName).
+ Set("environment_id", ctx.EnvironmentId).
+ Set("service", ctx.ServiceName).
+ Set("service_id", ctx.ServiceId).
+ Set("token_type", tokenType).
+ Set("os", runtime.GOOS).
+ Set("arch", runtime.GOARCH).
+ Set("command", commandName(command))
+
flags := []string{}
command.Flags().VisitAll(func(flag *pflag.Flag) {
if flag.Changed {
@@ -38,9 +86,9 @@ func Capture(command *cobra.Command) {
err = ph.Enqueue(posthog.Capture{
DistinctId: string(ctx.User),
- Event: "cli-command-execution",
+ Event: event,
Timestamp: time.Now(),
- Properties: properties,
+ Properties: mProperties,
})
if err != nil {
return
diff --git a/utils/posthog_test.go b/utils/posthog_test.go
new file mode 100644
index 00000000..ce88a004
--- /dev/null
+++ b/utils/posthog_test.go
@@ -0,0 +1,190 @@
+package utils
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+// Capture the real SDK's requests locally, without using the user's credentials
+// or contacting the telemetry service. These tests must not run in parallel.
+func setupTelemetryTest(t *testing.T, token string) <-chan []byte {
+ t.Helper()
+ dir := t.TempDir()
+ t.Setenv("HOME", dir)
+ t.Setenv("USERPROFILE", dir)
+ ctx := QoveryContext{
+ AccessToken: AccessToken(token),
+ RefreshToken: "refresh-token-secret",
+ User: "test-user",
+ OrganizationName: "test-org",
+ OrganizationId: "test-org-id",
+ }
+ contextPath, err := QoveryContextPath()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(contextPath), 0700); err != nil {
+ t.Fatal(err)
+ }
+ data, err := json.Marshal(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(contextPath, data, ContextFilePermissions); err != nil {
+ t.Fatal(err)
+ }
+
+ requests := make(chan []byte, 16)
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/batch/" {
+ t.Errorf("unexpected telemetry request: %s %s", r.Method, r.URL.Path)
+ }
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("reading telemetry request: %v", err)
+ }
+ requests <- body
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(server.Close)
+
+ transport := server.Client().Transport.(*http.Transport).Clone()
+ transport.TLSClientConfig.ServerName = "example.com"
+ transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
+ }
+ originalTransport := http.DefaultTransport
+ http.DefaultTransport = transport
+ t.Cleanup(func() {
+ http.DefaultTransport = originalTransport
+ transport.CloseIdleConnections()
+ })
+ return requests
+}
+
+func TestTelemetryPreservesErrorOutput(t *testing.T) {
+ for _, tokenType := range []string{"jwt", "static"} {
+ for _, name := range []string{"up", "destroy"} {
+ t.Run(tokenType+"/"+name, func(t *testing.T) {
+ token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.test-signature"
+ if tokenType == "static" {
+ token = "qov_test-static-token-secret"
+ }
+ requests := setupTelemetryTest(t, token)
+ t.Setenv("QOVERY_TELEMETRY", "true")
+
+ root := &cobra.Command{Use: "qovery", SilenceErrors: true, SilenceUsage: true}
+ demo := &cobra.Command{Use: "demo"}
+ command := &cobra.Command{
+ Use: name + " [args]",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cmd.Println("demo command output")
+ cmd.PrintErrln("demo script failed")
+ return fmt.Errorf("exit status 1")
+ },
+ }
+ root.AddCommand(demo)
+ demo.AddCommand(command)
+ command.Flags().String("token", "", "Authentication token")
+ root.SetArgs([]string{"demo", name, "--token", token, "argument-secret"})
+ var stdout, stderr bytes.Buffer
+ root.SetOut(&stdout)
+ root.SetErr(&stderr)
+ if err := root.Execute(); err == nil {
+ t.Fatal("expected command failure")
+ }
+ events := []struct {
+ name string
+ capture func(*cobra.Command)
+ }{
+ {DefaultEventName, Capture},
+ {EndOfExecutionErrorEventName, func(cmd *cobra.Command) { CaptureError(cmd, stdout.String(), stderr.String()) }},
+ {EndOfExecutionEventName, func(cmd *cobra.Command) { CaptureWithEvent(cmd, EndOfExecutionEventName) }},
+ }
+ for _, event := range events {
+ event.capture(command)
+ var body []byte
+ select {
+ case body = <-requests:
+ default:
+ t.Fatalf("missing %s telemetry request", event.name)
+ }
+ for _, secret := range []string{token, "refresh-token-secret", "argument-secret"} {
+ if bytes.Contains(body, []byte(secret)) {
+ t.Errorf("%s telemetry contains secret %q", event.name, secret)
+ }
+ }
+ var payload struct {
+ Batch []struct {
+ Event string `json:"event"`
+ DistinctID string `json:"distinct_id"`
+ Properties map[string]interface{} `json:"properties"`
+ } `json:"batch"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ t.Fatal(err)
+ }
+ if len(payload.Batch) != 1 {
+ t.Fatalf("got %d events, want 1", len(payload.Batch))
+ }
+ capture := payload.Batch[0]
+ if capture.Event != event.name || capture.DistinctID != "test-user" {
+ t.Errorf("unexpected event identity: %+v", capture)
+ }
+ want := map[string]string{
+ "command": "qovery demo " + name, "flags": "token", "token_type": tokenType,
+ "organization": "test-org", "organization_id": "test-org-id",
+ "project": "", "project_id": "", "environment": "", "environment_id": "",
+ "service": "", "service_id": "", "os": runtime.GOOS, "arch": runtime.GOARCH,
+ }
+ if event.name == EndOfExecutionErrorEventName {
+ want["stdout"] = "demo command output\n"
+ want["stderr"] = "demo script failed\n"
+ }
+ for key, value := range want {
+ if capture.Properties[key] != value {
+ t.Errorf("property %s = %v, want %q", key, capture.Properties[key], value)
+ }
+ }
+ for key := range capture.Properties {
+ // The SDK adds its own properties with the $ prefix.
+ if _, ok := want[key]; !ok && !strings.HasPrefix(key, "$") {
+ t.Errorf("unexpected telemetry property %q", key)
+ }
+ }
+ }
+ })
+ }
+ }
+}
+
+func TestTelemetryOptOutAppliesToAllEvents(t *testing.T) {
+ requests := setupTelemetryTest(t, "qov_test-static-token-secret")
+ command := &cobra.Command{Use: "qovery"}
+ for _, flag := range []string{"false", "FALSE", "FaLsE"} {
+ t.Run(flag, func(t *testing.T) {
+ t.Setenv("QOVERY_TELEMETRY", flag)
+ Capture(command)
+ CaptureError(command, "demo command output", "demo script failed")
+ CaptureWithEvent(command, EndOfExecutionEventName)
+ select {
+ case body := <-requests:
+ t.Fatalf("telemetry sent despite opt-out: %s", body)
+ default:
+ }
+ })
+ }
+}
diff --git a/utils/printer.go b/utils/printer.go
index bb605843..947f1a06 100644
--- a/utils/printer.go
+++ b/utils/printer.go
@@ -3,42 +3,42 @@ package utils
import (
"fmt"
"github.com/fatih/color"
- "github.com/getsentry/sentry-go"
+ // "github.com/getsentry/sentry-go"
"github.com/pterm/pterm"
log "github.com/sirupsen/logrus"
- "time"
+ // "time"
)
func PrintlnError(err error) {
- localHub := sentry.CurrentHub().Clone()
- localHub.Scope().SetTransaction(err.Error())
- localHub.CaptureException(err)
+ //localHub := sentry.CurrentHub().Clone()
+ //localHub.Scope().SetTransaction(err.Error())
+ //localHub.CaptureException(err)
fmt.Printf("%s: %v\n", color.RedString("Error"), err)
- defer localHub.Flush(5 * time.Second)
+ //defer localHub.Flush(5 * time.Second)
}
func PrintlnInfo(info string) {
- fmt.Printf("%v: %v\n", color.CyanString("Qovery"), info)
+ fmt.Printf("%v: %v\n", color.CyanString("Info"), info)
}
func Println(text string) {
fmt.Printf("%v\n", text)
}
-func PrintlnContext() error {
- _, oName, err := CurrentOrganization()
+func PrintContext() error {
+ _, oName, err := CurrentOrganization(false)
if err != nil {
return err
}
- _, pName, err := CurrentProject()
+ _, pName, err := CurrentProject(false)
if err != nil {
return err
}
- _, eName, err := CurrentEnvironment()
+ _, eName, err := CurrentEnvironment(false)
if err != nil {
return err
}
- _, aName, err := CurrentApplication()
+ srv, err := CurrentService(false)
if err != nil {
return err
}
@@ -46,21 +46,34 @@ func PrintlnContext() error {
{"Organization", string(oName)},
{"Project", string(pName)},
{"Environment", string(eName)},
- {"Application", string(aName)},
+ {"Service", string(srv.Name)},
+ {"Type", string(srv.Type)},
}).Render()
return nil
}
-func DryRunPrint(dryRunDisbled bool) {
+func DryRunPrint(dryRunDisabled bool) {
green := color.New(color.FgGreen).SprintFunc()
message := green("enabled")
- if dryRunDisbled {
+ if dryRunDisabled {
red := color.New(color.FgRed).SprintFunc()
message = red("disabled")
}
log.Infof("Dry run: %s", message)
}
+
+func PrintTable(headers []string, data [][]string) error {
+ table := pterm.TableData{
+ headers,
+ }
+
+ for _, row := range data {
+ table = append(table, row)
+ }
+
+ return pterm.DefaultTable.WithHasHeader().WithData(table).Render()
+}
diff --git a/utils/qovery.go b/utils/qovery.go
index aaf8646b..d8b21420 100644
--- a/utils/qovery.go
+++ b/utils/qovery.go
@@ -3,32 +3,151 @@ package utils
import (
"errors"
"fmt"
+ "net/http"
"os"
+ "sort"
+ "strconv"
"strings"
+ "time"
+ "unicode"
+ "context"
+
+ "github.com/qovery/qovery-cli/variable"
+
+ "github.com/pterm/pterm"
"github.com/manifoldco/promptui"
"github.com/qovery/qovery-client-go"
log "github.com/sirupsen/logrus"
- "golang.org/x/net/context"
)
+func init() {
+ log.SetFormatter(&log.TextFormatter{
+ FullTimestamp: true,
+ })
+}
+
+func sortNamesCaseInsensitive(names []string) {
+ sort.Slice(names, func(i, j int) bool {
+ return strings.ToLower(names[i]) < strings.ToLower(names[j])
+ })
+}
+
type Organization struct {
ID Id
Name Name
}
-const AdminUrl = "https://api-admin.qovery.com"
+type TokenInformation struct {
+ Organization *Organization
+ Role *Role
+ Name string
+ Description string
+}
+
+type Role struct {
+ ID string
+ Name Name
+}
+
+func WebsocketUrl() string {
+ if url := os.Getenv("QOVERY_WS_URL"); url != "" {
+ return url
+ }
+ return "wss://ws.qovery.com"
+}
+
+func GetQoveryClientPanicInCaseOfError() *qovery.APIClient {
+ tokenType, token, err := GetAccessToken(false)
+ CheckError(err)
+ return GetQoveryClient(tokenType, token)
+}
+
+func CheckError(err error) {
+ if err != nil {
+ PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+}
+
+func GetAPIBaseURL() string {
+ if url := os.Getenv("QOVERY_API_URL"); url != "" {
+ return strings.TrimRight(url, "/")
+ }
+ return "https://api.qovery.com"
+}
+
+func GetQoveryClient(tokenType AccessTokenType, token AccessToken) *qovery.APIClient {
+ conf := qovery.NewConfiguration()
+ conf.UserAgent = "CLI " + Version
+ if url := os.Getenv("QOVERY_API_URL"); url != "" {
+ conf.Servers = qovery.ServerConfigurations{{URL: GetAPIBaseURL(), Description: "No description provided"}}
+ }
+ conf.DefaultHeader["Authorization"] = GetAuthorizationHeaderValue(tokenType, token)
+ conf.Debug = variable.Verbose
+ conf.HTTPClient = &http.Client{
+ Timeout: time.Second * 60,
+ }
+ return qovery.NewAPIClient(conf)
+}
+
+func SelectRole(organization *Organization) (*Role, error) {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ roles, res, err := client.OrganizationMainCallsAPI.ListOrganizationAvailableRoles(context.Background(), string(organization.ID)).Execute()
+ if err != nil {
+ return nil, err
+ }
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while listing organizations. ")
+ }
+
+ var roleNames []string
+ var rolesIds = make(map[string]string)
+
+ for _, role := range roles.GetResults() {
+ roleNames = append(roleNames, role.Name)
+ rolesIds[role.Name] = role.Id
+ }
+
+ if len(roleNames) < 1 {
+ return nil, errors.New("no role found")
+ }
+
+ fmt.Println("Roles:")
+ prompt := promptui.Select{
+ Items: roleNames,
+ Searcher: func(input string, index int) bool {
+ return strings.Contains(strings.ToLower(roleNames[index]), strings.ToLower(input))
+ },
+ }
+ _, selectedRole, err := prompt.Run()
+ if err != nil {
+ return nil, err
+ }
+
+ return &Role{
+ ID: rolesIds[selectedRole],
+ Name: Name(selectedRole),
+ }, nil
+
+}
func SelectOrganization() (*Organization, error) {
- token, err := GetAccessToken()
+ tokenType, token, err := GetAccessToken(false)
if err != nil {
return nil, err
}
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
+ client := GetQoveryClient(tokenType, token)
- organizations, res, err := client.OrganizationMainCallsApi.ListOrganization(auth).Execute()
+ organizations, res, err := client.OrganizationMainCallsAPI.ListOrganization(context.Background()).Execute()
if err != nil {
return nil, err
}
@@ -37,15 +156,23 @@ func SelectOrganization() (*Organization, error) {
}
var organizationNames []string
- var orgas = make(map[string]string)
+ var orgs = make(map[string]string)
for _, org := range organizations.GetResults() {
organizationNames = append(organizationNames, org.Name)
- orgas[org.Name] = org.Id
+ orgs[org.Name] = org.Id
}
+ sortNamesCaseInsensitive(organizationNames)
if len(organizationNames) < 1 {
- return nil, errors.New("No organizations found. ")
+ return nil, errors.New("no organizations found")
+ }
+
+ if len(organizationNames) == 1 {
+ return &Organization{
+ ID: Id(orgs[organizationNames[0]]),
+ Name: Name(organizationNames[0]),
+ }, nil
}
fmt.Println("Organization:")
@@ -54,6 +181,7 @@ func SelectOrganization() (*Organization, error) {
Searcher: func(input string, index int) bool {
return strings.Contains(strings.ToLower(organizationNames[index]), strings.ToLower(input))
},
+ Size: 30,
}
_, selectedOrganization, err := prompt.Run()
if err != nil {
@@ -61,7 +189,7 @@ func SelectOrganization() (*Organization, error) {
}
return &Organization{
- ID: Id(orgas[selectedOrganization]),
+ ID: Id(orgs[selectedOrganization]),
Name: Name(selectedOrganization),
}, nil
}
@@ -69,9 +197,9 @@ func SelectOrganization() (*Organization, error) {
func SelectAndSetOrganization() (*Organization, error) {
selectedOrganization, err := SelectOrganization()
if err != nil {
- PrintlnError(err)
return nil, err
}
+
err = SetOrganization(selectedOrganization)
if err != nil {
PrintlnError(err)
@@ -86,16 +214,37 @@ type Project struct {
Name Name
}
+func GetOrganizationById(id string) (*Organization, error) {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ organization, res, err := client.OrganizationMainCallsAPI.GetOrganization(context.Background(), id).Execute()
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while getting organization " + id)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &Organization{
+ ID: Id(organization.Id),
+ Name: Name(organization.Name),
+ }, nil
+}
+
func SelectProject(organizationID Id) (*Project, error) {
- token, err := GetAccessToken()
+ tokenType, token, err := GetAccessToken(false)
if err != nil {
return nil, err
}
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
+ client := GetQoveryClient(tokenType, token)
- p, res, err := client.ProjectsApi.ListProject(auth, string(organizationID)).Execute()
+ p, res, err := client.ProjectsAPI.ListProject(context.Background(), string(organizationID)).Execute()
if err != nil {
return nil, err
}
@@ -110,9 +259,17 @@ func SelectProject(organizationID Id) (*Project, error) {
projectsNames = append(projectsNames, proj.Name)
projects[proj.Name] = proj.Id
}
+ sortNamesCaseInsensitive(projectsNames)
if len(projectsNames) < 1 {
- return nil, errors.New("No projects found. ")
+ return nil, errors.New("no projects found")
+ }
+
+ if len(projectsNames) == 1 {
+ return &Project{
+ ID: Id(projects[projectsNames[0]]),
+ Name: Name(projectsNames[0]),
+ }, nil
}
fmt.Println("Project:")
@@ -154,16 +311,37 @@ type Environment struct {
Name Name
}
+func GetProjectById(id string) (*Project, error) {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ project, res, err := client.ProjectMainCallsAPI.GetProject(context.Background(), id).Execute()
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while getting project " + id)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &Project{
+ ID: Id(project.Id),
+ Name: Name(project.Name),
+ }, nil
+}
+
func SelectEnvironment(projectID Id) (*Environment, error) {
- token, err := GetAccessToken()
+ tokenType, token, err := GetAccessToken(false)
if err != nil {
return nil, err
}
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
+ client := GetQoveryClient(tokenType, token)
- e, res, err := client.EnvironmentsApi.ListEnvironment(auth, string(projectID)).Execute()
+ e, res, err := client.EnvironmentsAPI.ListEnvironment(context.Background(), string(projectID)).Execute()
if err != nil {
return nil, err
}
@@ -172,15 +350,24 @@ func SelectEnvironment(projectID Id) (*Environment, error) {
}
var environmentsNames []string
- var environments = make(map[string]qovery.EnvironmentResponse)
+ var environments = make(map[string]qovery.Environment)
for _, env := range e.GetResults() {
environmentsNames = append(environmentsNames, env.Name)
environments[env.Name] = env
}
+ sortNamesCaseInsensitive(environmentsNames)
if len(environmentsNames) < 1 {
- return nil, errors.New("No environments found. ")
+ return nil, errors.New("no environments found")
+ }
+
+ if len(environmentsNames) == 1 {
+ return &Environment{
+ ID: Id(environments[environmentsNames[0]].Id),
+ Name: Name(environmentsNames[0]),
+ ClusterID: Id(environments[environmentsNames[0]].ClusterId),
+ }, nil
}
fmt.Println("Environment:")
@@ -205,7 +392,6 @@ func SelectEnvironment(projectID Id) (*Environment, error) {
func SelectAndSetEnvironment(projectID Id) (*Environment, error) {
selectedEnvironment, err := SelectEnvironment(projectID)
if err != nil {
- PrintlnError(err)
return nil, err
}
@@ -218,74 +404,297 @@ func SelectAndSetEnvironment(projectID Id) (*Environment, error) {
return selectedEnvironment, nil
}
+func GetEnvironmentById(id string) (*Environment, error) {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ environment, res, err := client.EnvironmentMainCallsAPI.GetEnvironment(context.Background(), id).Execute()
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while getting environment " + id)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &Environment{
+ ID: Id(environment.Id),
+ ClusterID: Id(environment.ClusterId),
+ Name: Name(environment.Name),
+ }, nil
+}
+
+type EnvironmentService struct {
+ ID string
+ Type ServiceType
+}
+
+func GetEnvironmentServicesById(id string) ([]EnvironmentService, error) {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ environmentServices, res, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), id).Execute()
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while getting environment services" + id)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ var services []EnvironmentService
+ for _, service := range environmentServices.Applications {
+ services = append(services, EnvironmentService{
+ ID: service.Id,
+ Type: ApplicationType,
+ })
+ }
+ for _, service := range environmentServices.Containers {
+ services = append(services, EnvironmentService{
+ ID: service.Id,
+ Type: ContainerType,
+ })
+ }
+ for _, service := range environmentServices.Jobs {
+ services = append(services, EnvironmentService{
+ ID: service.Id,
+ Type: JobType,
+ })
+ }
+ for _, service := range environmentServices.Databases {
+ services = append(services, EnvironmentService{
+ ID: service.Id,
+ Type: DatabaseType,
+ })
+ }
+
+ for _, service := range environmentServices.Helms {
+ services = append(services, EnvironmentService{
+ ID: service.Id,
+ Type: HelmType,
+ })
+ }
+
+ return services, nil
+}
+
+type ServiceType string
+
+const (
+ ApplicationType ServiceType = "application"
+ ContainerType ServiceType = "container"
+ DatabaseType ServiceType = "database"
+ JobType ServiceType = "job"
+ HelmType ServiceType = "helm"
+ TerraformType ServiceType = "terraform"
+)
+
+type Service struct {
+ ID Id
+ Name Name
+ Type ServiceType
+}
+
type Application struct {
ID Id
Name Name
}
-func SelectApplication(environment Id) (*Application, error) {
- token, err := GetAccessToken()
+func SelectService(environment Id) (*Service, error) {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ apps, res, err := client.ApplicationsAPI.ListApplication(context.Background(), string(environment)).Execute()
+ if err != nil {
+ return nil, err
+ }
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while listing services. ")
+ }
+
+ containers, res, err := client.ContainersAPI.ListContainer(context.Background(), string(environment)).Execute()
+ if err != nil {
+ return nil, err
+ }
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while listing containers. ")
+ }
+
+ databases, res, err := client.DatabasesAPI.ListDatabase(context.Background(), string(environment)).Execute()
+ if err != nil {
+ return nil, err
+ }
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while listing containers. ")
+ }
+
+ jobs, res, err := client.JobsAPI.ListJobs(context.Background(), string(environment)).Execute()
if err != nil {
return nil, err
}
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while listing containers. ")
+ }
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
+ helms, res, err := client.HelmsAPI.ListHelms(context.Background(), string(environment)).Execute()
+ if err != nil {
+ return nil, err
+ }
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while listing helms. ")
+ }
- a, res, err := client.ApplicationsApi.ListApplication(auth, string(environment)).Execute()
+ terraforms, res, err := client.TerraformsAPI.ListTerraforms(context.Background(), string(environment)).Execute()
if err != nil {
return nil, err
}
if res.StatusCode >= 400 {
- return nil, errors.New("Received " + res.Status + " response while listing applications. ")
+ return nil, errors.New("Received " + res.Status + " response while listing terraforms. ")
+ }
+
+ var servicesNames []string
+ var services = make(map[string]Service)
+
+ for _, app := range apps.GetResults() {
+ servicesNames = append(servicesNames, app.Name)
+ services[app.Name] = Service{
+ ID: Id(app.Id),
+ Name: Name(app.Name),
+ Type: ApplicationType,
+ }
+ }
+
+ for _, container := range containers.GetResults() {
+ servicesNames = append(servicesNames, container.Name)
+ services[container.Name] = Service{
+ ID: Id(container.Id),
+ Name: Name(container.Name),
+ Type: ContainerType,
+ }
+ }
+
+ for _, database := range databases.GetResults() {
+ servicesNames = append(servicesNames, database.Name)
+ services[database.Name] = Service{
+ ID: Id(database.Id),
+ Name: Name(database.Name),
+ Type: DatabaseType,
+ }
+ }
+
+ for _, job := range jobs.GetResults() {
+ if job.CronJobResponse != nil {
+ cronJob := job.CronJobResponse
+ servicesNames = append(servicesNames, cronJob.Name)
+ services[cronJob.Name] = Service{
+ ID: Id(cronJob.Id),
+ Name: Name(cronJob.Name),
+ Type: JobType,
+ }
+ }
+ if job.LifecycleJobResponse != nil {
+ lifecycleJob := job.LifecycleJobResponse
+ servicesNames = append(servicesNames, lifecycleJob.Name)
+ services[lifecycleJob.Name] = Service{
+ ID: Id(lifecycleJob.Id),
+ Name: Name(lifecycleJob.Name),
+ Type: JobType,
+ }
+ }
}
- var applicationsNames []string
- var applications = make(map[string]string)
+ for _, helm := range helms.GetResults() {
+ servicesNames = append(servicesNames, helm.Name)
+ services[helm.Name] = Service{
+ ID: Id(helm.Id),
+ Name: Name(helm.Name),
+ Type: HelmType,
+ }
+ }
+
+ for _, terraform := range terraforms.GetResults() {
+ servicesNames = append(servicesNames, terraform.Name)
+ services[terraform.Name] = Service{
+ ID: Id(terraform.Id),
+ Name: Name(terraform.Name),
+ Type: TerraformType,
+ }
+ }
+ sortNamesCaseInsensitive(servicesNames)
- for _, app := range a.GetResults() {
- applicationsNames = append(applicationsNames, *app.Name)
- applications[*app.Name] = app.Id
+ if len(servicesNames) < 1 {
+ return nil, errors.New("no services found")
}
- if len(applicationsNames) < 1 {
- return nil, errors.New("No applications found. ")
+ if len(servicesNames) == 1 {
+ service := services[servicesNames[0]]
+ return &service, nil
}
- fmt.Println("Application:")
+ fmt.Println("Services:")
prompt := promptui.Select{
- Items: applicationsNames,
+ Items: servicesNames,
Searcher: func(input string, index int) bool {
- return strings.Contains(strings.ToLower(applicationsNames[index]), strings.ToLower(input))
+ return strings.Contains(strings.ToLower(servicesNames[index]), strings.ToLower(input))
},
}
- _, selectedApplication, err := prompt.Run()
+ _, selectedService, err := prompt.Run()
if err != nil {
PrintlnError(err)
return nil, err
}
- return &Application{
- ID: Id(applications[selectedApplication]),
- Name: Name(selectedApplication),
- }, nil
+ service := services[selectedService]
+ return &service, nil
}
-func SelectAndSetApplication(environment Id) (*Application, error) {
- application, err := SelectApplication(environment)
+func SelectAndSetService(environment Id) (*Service, error) {
+ service, err := SelectService(environment)
if err != nil {
PrintlnError(err)
return nil, err
}
- if err := SetApplication(application); err != nil {
+ if err := SetService(service); err != nil {
PrintlnError(err)
return nil, err
}
- return application, err
+ return service, err
+}
+
+func GetApplicationById(id string) (*Application, error) {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ application, res, err := client.ApplicationMainCallsAPI.GetApplication(context.Background(), id).Execute()
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while getting application " + id)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &Application{
+ ID: Id(application.Id),
+ Name: Name(application.GetName()),
+ }, nil
}
func ResetApplicationContext() error {
- ctx, err := CurrentContext()
+ ctx, err := GetCurrentContext()
if err != nil {
return err
}
@@ -296,105 +705,217 @@ func ResetApplicationContext() error {
ctx.ProjectId = ""
ctx.EnvironmentName = ""
ctx.EnvironmentId = ""
- ctx.ApplicationName = ""
- ctx.ApplicationId = ""
+ ctx.ServiceName = ""
+ ctx.ServiceId = ""
+ ctx.ServiceType = ApplicationType
err = StoreContext(ctx)
return err
}
-func CheckAdminUrl() {
- if _, ok := os.LookupEnv("ADMIN_URL"); !ok {
- log.Error("You must set the Qovery admin root url (ADMIN_URL).")
- os.Exit(1)
- }
+type Container struct {
+ ID Id
+ Name Name
}
-func DeleteEnvironmentVariable(application Id, key string) error {
- token, err := GetAccessToken()
+func GetContainerById(id string) (*Container, error) {
+ tokenType, token, err := GetAccessToken(false)
if err != nil {
- return err
+ return nil, err
}
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
-
- // TODO optimize this call by caching the result?
- envVars, _, err := client.ApplicationEnvironmentVariableApi.ListApplicationEnvironmentVariable(auth, string(application)).Execute()
-
- if err != nil {
- return err
- }
+ client := GetQoveryClient(tokenType, token)
- var envVar *qovery.EnvironmentVariableResponse
- for _, mEnvVar := range envVars.GetResults() {
- if mEnvVar.Key == key {
- envVar = &mEnvVar
- break
- }
+ container, res, err := client.ContainerMainCallsAPI.GetContainer(context.Background(), id).Execute()
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while getting container " + id)
}
-
- if envVar == nil {
- return nil
+ if err != nil {
+ return nil, err
}
- res, err := client.ApplicationEnvironmentVariableApi.DeleteApplicationEnvironmentVariable(auth, string(application), envVar.Id).Execute()
+ return &Container{
+ ID: Id(container.Id),
+ Name: Name(container.GetName()),
+ }, nil
+}
+func GetDatabaseById(id string) (*Service, error) {
+ tokenType, token, err := GetAccessToken(false)
if err != nil {
- return err
+ return nil, err
}
+ client := GetQoveryClient(tokenType, token)
+
+ database, res, err := client.DatabaseMainCallsAPI.GetDatabase(context.Background(), id).Execute()
if res.StatusCode >= 400 {
- return fmt.Errorf("Received "+res.Status+" response while deleting an Environment Variable for application %s with key %s", string(application), key)
+ return nil, errors.New("Received " + res.Status + " response while getting database " + id)
}
-
- return nil
-}
-
-func AddEnvironmentVariable(application Id, key string, value string) error {
- token, err := GetAccessToken()
if err != nil {
- return err
+ return nil, err
}
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
-
- _, res, err := client.ApplicationEnvironmentVariableApi.CreateApplicationEnvironmentVariable(auth, string(application)).EnvironmentVariableRequest(
- qovery.EnvironmentVariableRequest{Key: key, Value: value},
- ).Execute()
+ return &Service{
+ ID: Id(database.Id),
+ Name: Name(database.GetName()),
+ Type: DatabaseType,
+ }, nil
+}
+func GetHelmById(id string) (*Service, error) {
+ tokenType, token, err := GetAccessToken(false)
if err != nil {
- return err
+ return nil, err
}
+ client := GetQoveryClient(tokenType, token)
+
+ helm, res, err := client.HelmMainCallsAPI.GetHelm(context.Background(), id).Execute()
if res.StatusCode >= 400 {
- return fmt.Errorf("Received "+res.Status+" response while adding an environment variable for application %s", string(application))
+ return nil, errors.New("Received " + res.Status + " response while getting helm " + id)
+ }
+ if err != nil {
+ return nil, err
}
- return nil
+ return &Service{
+ ID: Id(helm.Id),
+ Name: Name(helm.GetName()),
+ Type: HelmType,
+ }, nil
+}
+
+type Job struct {
+ ID Id
+ Name Name
+}
+
+func GetJobById(id string) (*Job, error) {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ job, res, err := client.JobMainCallsAPI.GetJob(context.Background(), id).Execute()
+ if res.StatusCode >= 400 {
+ return nil, errors.New("Received " + res.Status + " response while getting job " + id)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if job.LifecycleJobResponse != nil {
+ return &Job{
+ ID: Id(job.LifecycleJobResponse.Id),
+ Name: Name(job.LifecycleJobResponse.GetName()),
+ }, nil
+ }
+
+ if job.CronJobResponse != nil {
+ return &Job{
+ ID: Id(job.CronJobResponse.Id),
+ Name: Name(job.CronJobResponse.GetName()),
+ }, nil
+ }
+
+ return nil, errors.New("invalid job response")
+}
+
+func GetAdminUrl() string {
+ url, ok := os.LookupEnv("ADMIN_URL")
+ if !ok {
+ log.Fatal("You must set the Qovery admin root url (ADMIN_URL).")
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+ return url
+}
+
+func DeleteEnvironmentVariable(application Id, key string) error {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ // TODO optimize this call by caching the result?
+ envVars, _, err := client.ApplicationEnvironmentVariableAPI.ListApplicationEnvironmentVariable(context.Background(), string(application)).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ var envVar *qovery.EnvironmentVariable
+ for _, mEnvVar := range envVars.GetResults() {
+ if mEnvVar.Key == key {
+ envVar = &mEnvVar
+ break
+ }
+ }
+
+ if envVar == nil {
+ return nil
+ }
+
+ res, err := client.ApplicationEnvironmentVariableAPI.DeleteApplicationEnvironmentVariable(context.Background(), string(application), envVar.Id).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ if res.StatusCode >= 400 {
+ return fmt.Errorf("Received "+res.Status+" response while deleting an Environment Variable for application %s with key %s", string(application), key)
+ }
+
+ return nil
+}
+
+func AddEnvironmentVariable(application Id, key string, value string) error {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ _, res, err := client.ApplicationEnvironmentVariableAPI.CreateApplicationEnvironmentVariable(context.Background(), string(application)).EnvironmentVariableRequest(
+ qovery.EnvironmentVariableRequest{Key: key, Value: &value},
+ ).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ if res.StatusCode >= 400 {
+ return fmt.Errorf("Received "+res.Status+" response while adding an environment variable for application %s", string(application))
+ }
+
+ return nil
}
func DeleteSecret(application Id, key string) error {
- token, err := GetAccessToken()
+ tokenType, token, err := GetAccessToken(false)
if err != nil {
return err
}
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
+ client := GetQoveryClient(tokenType, token)
// TODO optimize this call by caching the result?
- secrets, _, err := client.ApplicationSecretApi.ListApplicationSecrets(auth, string(application)).Execute()
+ secrets, _, err := client.ApplicationSecretAPI.ListApplicationSecrets(context.Background(), string(application)).Execute()
if err != nil {
return err
}
- var secret *qovery.SecretResponse
+ var secret *qovery.Secret
for _, mSecret := range secrets.GetResults() {
- if *mSecret.Key == key {
+ if mSecret.Key == key {
secret = &mSecret
break
}
@@ -404,7 +925,7 @@ func DeleteSecret(application Id, key string) error {
return nil
}
- res, err := client.ApplicationSecretApi.DeleteApplicationSecret(auth, string(application), secret.Id).Execute()
+ res, err := client.ApplicationSecretAPI.DeleteApplicationSecret(context.Background(), string(application), secret.Id).Execute()
if err != nil {
return err
@@ -418,16 +939,15 @@ func DeleteSecret(application Id, key string) error {
}
func AddSecret(application Id, key string, value string) error {
- token, err := GetAccessToken()
+ tokenType, token, err := GetAccessToken(false)
if err != nil {
return err
}
- auth := context.WithValue(context.Background(), qovery.ContextAccessToken, string(token))
- client := qovery.NewAPIClient(qovery.NewConfiguration())
+ client := GetQoveryClient(tokenType, token)
- _, res, err := client.ApplicationSecretApi.CreateApplicationSecret(auth, string(application)).SecretRequest(
- qovery.SecretRequest{Key: key, Value: value},
+ _, res, err := client.ApplicationSecretAPI.CreateApplicationSecret(context.Background(), string(application)).SecretRequest(
+ qovery.SecretRequest{Key: key, Value: &value},
).Execute()
if err != nil {
@@ -440,3 +960,1227 @@ func AddSecret(application Id, key string, value string) error {
return nil
}
+
+// Container environment variable functions
+
+func AddContainerEnvironmentVariable(container Id, key string, value string) error {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ _, res, err := client.ContainerEnvironmentVariableAPI.CreateContainerEnvironmentVariable(context.Background(), string(container)).EnvironmentVariableRequest(
+ qovery.EnvironmentVariableRequest{Key: key, Value: &value},
+ ).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ if res.StatusCode >= 400 {
+ return fmt.Errorf("Received "+res.Status+" response while adding an environment variable for container %s", string(container))
+ }
+
+ return nil
+}
+
+func DeleteContainerEnvironmentVariable(container Id, key string) error {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ // TODO optimize this call by caching the result?
+ envVars, _, err := client.ContainerEnvironmentVariableAPI.ListContainerEnvironmentVariable(context.Background(), string(container)).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ var envVar *qovery.EnvironmentVariable
+ for _, mEnvVar := range envVars.GetResults() {
+ if mEnvVar.Key == key {
+ envVar = &mEnvVar
+ break
+ }
+ }
+
+ if envVar == nil {
+ return nil
+ }
+
+ res, err := client.ContainerEnvironmentVariableAPI.DeleteContainerEnvironmentVariable(context.Background(), string(container), envVar.Id).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ if res.StatusCode >= 400 {
+ return fmt.Errorf("Received "+res.Status+" response while deleting an Environment Variable for container %s with key %s", string(container), key)
+ }
+
+ return nil
+}
+
+func AddContainerSecret(container Id, key string, value string) error {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ _, res, err := client.ContainerSecretAPI.CreateContainerSecret(context.Background(), string(container)).SecretRequest(
+ qovery.SecretRequest{Key: key, Value: &value},
+ ).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ if res.StatusCode >= 400 {
+ return fmt.Errorf("Received "+res.Status+" response while adding a secret for container %s", string(container))
+ }
+
+ return nil
+}
+
+func DeleteContainerSecret(container Id, key string) error {
+ tokenType, token, err := GetAccessToken(false)
+ if err != nil {
+ return err
+ }
+
+ client := GetQoveryClient(tokenType, token)
+
+ // TODO optimize this call by caching the result?
+ secrets, _, err := client.ContainerSecretAPI.ListContainerSecrets(context.Background(), string(container)).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ var secret *qovery.Secret
+ for _, mSecret := range secrets.GetResults() {
+ if mSecret.Key == key {
+ secret = &mSecret
+ break
+ }
+ }
+
+ if secret == nil {
+ return nil
+ }
+
+ res, err := client.ContainerSecretAPI.DeleteContainerSecret(context.Background(), string(container), secret.Id).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ if res.StatusCode >= 400 {
+ return fmt.Errorf("Received "+res.Status+" response while deleting a secret for container %s with key %s", string(container), key)
+ }
+
+ return nil
+}
+
+func SelectTokenInformation() (*TokenInformation, error) {
+ organization, err := SelectOrganization()
+
+ if err != nil {
+ return nil, err
+ }
+
+ PrintlnInfo("Select Role")
+ role, err := SelectRole(organization)
+ if err != nil {
+ return nil, err
+ }
+
+ fmt.Println("Choose a token name")
+ promptName := promptui.Prompt{
+ Label: "Token name",
+ }
+ name, err := promptName.Run()
+
+ if err != nil {
+ return nil, err
+ }
+
+ if len(strings.Trim(name, "")) == 0 {
+ return nil, errors.New("token name must not be empty")
+ }
+
+ fmt.Println("Choose a token description")
+ promptDescription := promptui.Prompt{
+ Label: "Token description",
+ }
+ description, err := promptDescription.Run()
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &TokenInformation{
+ organization,
+ role,
+ name,
+ description,
+ }, nil
+}
+
+func FindStatus(statuses []qovery.Status, serviceId string) string {
+ status := "Unknown"
+
+ for _, s := range statuses {
+ if serviceId == s.Id {
+ return string(s.State)
+ }
+ }
+
+ return status
+}
+
+func FindStatusTextWithColor(statuses []qovery.Status, serviceId string) string {
+ status := "Unknown"
+
+ for _, s := range statuses {
+ if serviceId == s.Id {
+ return GetStatusTextWithColor(s.State)
+ }
+ }
+
+ return status
+}
+
+func GetEnvironmentStatus(statuses []qovery.EnvironmentStatus, serviceId string) string {
+ status := "Unknown"
+
+ for _, s := range statuses {
+ if serviceId == s.Id {
+ return string(s.State)
+ }
+ }
+
+ return status
+}
+
+func GetEnvironmentStatusWithColor(statuses []qovery.EnvironmentStatus, serviceId string) string {
+ status := "Unknown"
+
+ for _, s := range statuses {
+ if serviceId == s.Id {
+ return GetStatusTextWithColor(s.State)
+ }
+ }
+
+ return status
+}
+
+func GetStatusTextWithColor(s qovery.StateEnum) string {
+ var state = string(s)
+ var statusMsg string
+
+ if s == qovery.STATEENUM_DEPLOYED || s == qovery.STATEENUM_RESTARTED {
+ statusMsg = pterm.FgGreen.Sprintf("%s", state)
+ } else if strings.HasSuffix(string(s), "ERROR") {
+ statusMsg = pterm.FgRed.Sprintf("%s", state)
+ } else if strings.HasSuffix(string(s), "ING") {
+ statusMsg = pterm.FgLightBlue.Sprintf("%s", state)
+ } else if strings.HasSuffix(string(s), "QUEUED") {
+ statusMsg = pterm.FgLightYellow.Sprintf("%s", state)
+ } else if s == qovery.STATEENUM_READY {
+ statusMsg = pterm.FgYellow.Sprintf("%s", state)
+ } else if s == qovery.STATEENUM_STOPPED {
+ statusMsg = pterm.FgYellow.Sprintf("%s", state)
+ } else {
+ statusMsg = string(s)
+ }
+
+ return statusMsg
+}
+
+func GetClusterStatusTextWithColor(s qovery.ClusterStateEnum) string {
+ var statusMsg string
+
+ state := string(s)
+ if s == qovery.CLUSTERSTATEENUM_DEPLOYED || s == qovery.CLUSTERSTATEENUM_RESTARTED {
+ statusMsg = pterm.FgGreen.Sprintf("%s", state)
+ } else if strings.HasSuffix(state, "ERROR") || s == qovery.CLUSTERSTATEENUM_INVALID_CREDENTIALS {
+ statusMsg = pterm.FgRed.Sprintf("%s", state)
+ } else if strings.HasSuffix(state, "ING") {
+ statusMsg = pterm.FgLightBlue.Sprintf("%s", state)
+ } else if strings.HasSuffix(state, "QUEUED") {
+ statusMsg = pterm.FgLightYellow.Sprintf("%s", state)
+ } else if s == qovery.CLUSTERSTATEENUM_READY {
+ statusMsg = pterm.FgYellow.Sprintf("%s", state)
+ } else if s == qovery.CLUSTERSTATEENUM_STOPPED {
+ statusMsg = pterm.FgYellow.Sprintf("%s", state)
+ } else {
+ statusMsg = state
+ }
+
+ return statusMsg
+}
+
+// trimName strips all Unicode whitespace (including non-breaking spaces U+00A0)
+// from both ends of s so that name comparisons are resilient to copy-paste artefacts.
+func trimName(s string) string {
+ return strings.TrimFunc(s, unicode.IsSpace)
+}
+
+func FindByOrganizationName(organizations []qovery.Organization, name string) *qovery.Organization {
+ name = trimName(name)
+ for _, o := range organizations {
+ if trimName(o.Name) == name {
+ return &o
+ }
+ }
+
+ return nil
+}
+
+func FindByProjectName(projects []qovery.Project, name string) *qovery.Project {
+ name = trimName(name)
+ for _, p := range projects {
+ if trimName(p.Name) == name {
+ return &p
+ }
+ }
+
+ return nil
+}
+
+func FindByEnvironmentName(environments []qovery.Environment, name string) *qovery.Environment {
+ name = trimName(name)
+ for _, e := range environments {
+ if trimName(e.Name) == name {
+ return &e
+ }
+ }
+
+ return nil
+}
+
+func FindByApplicationName(applications []qovery.Application, name string) *qovery.Application {
+ name = trimName(name)
+ for _, a := range applications {
+ if trimName(a.Name) == name {
+ return &a
+ }
+ }
+
+ return nil
+}
+
+func FindByClusterName(clusters []qovery.Cluster, name string) *qovery.Cluster {
+ name = trimName(name)
+ for _, c := range clusters {
+ if trimName(c.Name) == name {
+ return &c
+ }
+ }
+
+ return nil
+}
+
+func FindByContainerName(containers []qovery.ContainerResponse, name string) *qovery.ContainerResponse {
+ name = trimName(name)
+ for _, c := range containers {
+ if trimName(c.Name) == name {
+ return &c
+ }
+ }
+
+ return nil
+}
+
+func FindByJobName(jobs []qovery.JobResponse, name string) *qovery.JobResponse {
+ name = trimName(name)
+ for _, j := range jobs {
+ if j.CronJobResponse != nil && trimName(j.CronJobResponse.Name) == name {
+ return &j
+ }
+ if j.LifecycleJobResponse != nil && trimName(j.LifecycleJobResponse.Name) == name {
+ return &j
+ }
+ }
+
+ return nil
+}
+
+func FindByDatabaseName(databases []qovery.Database, name string) *qovery.Database {
+ name = trimName(name)
+ for _, d := range databases {
+ if trimName(d.Name) == name {
+ return &d
+ }
+ }
+
+ return nil
+}
+
+func FindByHelmName(helms []qovery.HelmResponse, name string) *qovery.HelmResponse {
+ name = trimName(name)
+ for _, h := range helms {
+ if trimName(h.Name) == name {
+ return &h
+ }
+ }
+
+ return nil
+}
+
+func FindByTerraformName(terraforms []qovery.TerraformResponse, name string) *qovery.TerraformResponse {
+ name = trimName(name)
+ for _, t := range terraforms {
+ if trimName(t.Name) == name {
+ return &t
+ }
+ }
+
+ return nil
+}
+
+func FindByCustomDomainName(customDomains []qovery.CustomDomain, name string) *qovery.CustomDomain {
+ for _, d := range customDomains {
+ if d.Domain == name {
+ return &d
+ }
+ }
+
+ return nil
+}
+
+func WatchEnvironment(envId string, finalServiceState qovery.StateEnum, client *qovery.APIClient) {
+ WatchEnvironmentWithOptions(envId, finalServiceState, client, false)
+}
+
+func WatchEnvironmentWithOptions(envId string, finalServiceState qovery.StateEnum, client *qovery.APIClient, displaySimpleText bool) {
+ for {
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+
+ if err != nil {
+ return
+ }
+
+ if displaySimpleText {
+ // TODO make something more fancy here to display the status. Use UILIVE or something like that
+ log.Println(GetStatusTextWithColor(statuses.Environment.LastDeploymentState))
+ } else {
+ countStatuses := countStatus(statuses.Applications, finalServiceState) +
+ countStatus(statuses.Databases, finalServiceState) +
+ countStatus(statuses.Jobs, finalServiceState) +
+ countStatus(statuses.Containers, finalServiceState) +
+ countStatus(statuses.Helms, finalServiceState) +
+ countStatus(statuses.Terraforms, finalServiceState)
+
+ totalStatuses := len(statuses.Applications) + len(statuses.Databases) + len(statuses.Jobs) + len(statuses.Containers) + len(statuses.Helms) + len(statuses.Terraforms)
+
+ icon := "âŗ"
+ if countStatuses > 0 {
+ icon = "â
"
+ }
+
+ // TODO make something more fancy here to display the status. Use UILIVE or something like that
+ log.Println(GetStatusTextWithColor(statuses.Environment.LastDeploymentState) + " (" + strconv.Itoa(countStatuses) + "/" + strconv.Itoa(totalStatuses) + " services " + icon + " )")
+ }
+
+ if statuses.Environment.LastDeploymentState == qovery.STATEENUM_DEPLOYED ||
+ statuses.Environment.LastDeploymentState == qovery.STATEENUM_RESTARTED ||
+ statuses.Environment.LastDeploymentState == qovery.STATEENUM_DELETED ||
+ statuses.Environment.LastDeploymentState == qovery.STATEENUM_STOPPED ||
+ statuses.Environment.LastDeploymentState == qovery.STATEENUM_CANCELED {
+ return
+ }
+
+ if strings.HasSuffix(string(statuses.Environment.LastDeploymentState), "ERROR") {
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ time.Sleep(3 * time.Second)
+ }
+}
+
+func WatchContainer(containerId string, envId string, client *qovery.APIClient) {
+out:
+ for {
+ status, _, err := client.ContainerMainCallsAPI.GetContainerStatus(context.Background(), containerId).Execute()
+
+ if err != nil {
+ break
+ }
+
+ switch WatchStatus(status) {
+ case Continue:
+ case Stop:
+ break out
+ case Err:
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ time.Sleep(3 * time.Second)
+ }
+
+ log.Println("Check environment status..")
+
+ // check status of environment
+ WatchEnvironmentWithOptions(envId, "unused", client, true)
+}
+
+func WatchApplication(applicationId string, envId string, client *qovery.APIClient) {
+out:
+ for {
+ status, _, err := client.ApplicationMainCallsAPI.GetApplicationStatus(context.Background(), applicationId).Execute()
+
+ if err != nil {
+ break
+ }
+
+ switch WatchStatus(status) {
+ case Continue:
+ case Stop:
+ break out
+ case Err:
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ time.Sleep(3 * time.Second)
+ }
+
+ log.Println("Check environment status..")
+
+ // check status of environment
+ WatchEnvironmentWithOptions(envId, "unused", client, true)
+}
+
+func WatchDatabase(databaseId string, envId string, client *qovery.APIClient) {
+out:
+ for {
+ status, _, err := client.DatabaseMainCallsAPI.GetDatabaseStatus(context.Background(), databaseId).Execute()
+
+ if err != nil {
+ break
+ }
+
+ switch WatchStatus(status) {
+ case Continue:
+ case Stop:
+ break out
+ case Err:
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ time.Sleep(3 * time.Second)
+ }
+
+ log.Println("Check environment status..")
+
+ // check status of environment
+ WatchEnvironmentWithOptions(envId, "unused", client, true)
+}
+
+func WatchJob(jobId string, envId string, client *qovery.APIClient) {
+out:
+ for {
+ status, _, err := client.JobMainCallsAPI.GetJobStatus(context.Background(), jobId).Execute()
+
+ if err != nil {
+ break
+ }
+
+ switch WatchStatus(status) {
+ case Continue:
+ case Stop:
+ break out
+ case Err:
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ time.Sleep(3 * time.Second)
+ }
+
+ log.Println("Check environment status..")
+
+ // check status of environment
+ WatchEnvironmentWithOptions(envId, "unused", client, true)
+}
+
+func WatchHelm(helmId string, envId string, client *qovery.APIClient) {
+out:
+ for {
+ status, _, err := client.HelmMainCallsAPI.GetHelmStatus(context.Background(), helmId).Execute()
+
+ if err != nil {
+ break
+ }
+
+ switch WatchStatus(status) {
+ case Continue:
+ case Stop:
+ break out
+ case Err:
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ time.Sleep(3 * time.Second)
+ }
+
+ log.Println("Check environment status..")
+
+ // check status of environment
+ WatchEnvironmentWithOptions(envId, "unused", client, true)
+}
+
+type Status int8
+
+const (
+ Continue Status = iota
+ Stop
+ Err
+)
+
+func WatchStatus(status *qovery.Status) Status {
+ // TODO make something more fancy here to display the status. Use UILIVE or something like that
+ log.Println(GetStatusTextWithColor(status.State))
+
+ if status.State == qovery.STATEENUM_DEPLOYED || status.State == qovery.STATEENUM_DELETED ||
+ status.State == qovery.STATEENUM_STOPPED || status.State == qovery.STATEENUM_CANCELED ||
+ status.State == qovery.STATEENUM_RESTARTED {
+ return Stop
+ }
+
+ if strings.HasSuffix(string(status.State), "ERROR") {
+ return Err
+ }
+
+ return Continue
+}
+
+func countStatus(statuses []qovery.Status, state qovery.StateEnum) int {
+ count := 0
+
+ for _, s := range statuses {
+ if s.State == state {
+ count++
+ }
+ }
+
+ return count
+}
+
+func GetServiceNameByIdAndType(client *qovery.APIClient, serviceId string, serviceType string) string {
+ switch serviceType {
+ case "APPLICATION":
+ application, _, err := client.ApplicationMainCallsAPI.GetApplication(context.Background(), serviceId).Execute()
+ if err != nil {
+ return ""
+ }
+ return application.GetName()
+ case "DATABASE":
+ database, _, err := client.DatabaseMainCallsAPI.GetDatabase(context.Background(), serviceId).Execute()
+ if err != nil {
+ return ""
+ }
+ return database.GetName()
+ case "CONTAINER":
+ container, _, err := client.ContainerMainCallsAPI.GetContainer(context.Background(), serviceId).Execute()
+ if err != nil {
+ return ""
+ }
+ return container.GetName()
+ case "JOB":
+ job, _, err := client.JobMainCallsAPI.GetJob(context.Background(), serviceId).Execute()
+ if err != nil {
+ return ""
+ }
+ return GetJobName(job)
+ case "HELM":
+ helm, _, err := client.HelmMainCallsAPI.GetHelm(context.Background(), serviceId).Execute()
+ if err != nil {
+ return ""
+ }
+ return helm.GetName()
+ default:
+ return "Unknown"
+ }
+}
+
+func GetDeploymentStageId(client *qovery.APIClient, serviceId string) string {
+ sourceDeploymentStage, _, err := client.DeploymentStageMainCallsAPI.GetServiceDeploymentStage(context.Background(), serviceId).Execute()
+
+ if err != nil {
+ PrintlnError(err)
+ os.Exit(1)
+ panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011
+ }
+
+ return sourceDeploymentStage.Id
+}
+
+func DeployApplications(client *qovery.APIClient, envId string, applicationList []*qovery.Application, commitId string) error {
+ if len(applicationList) == 0 {
+ return nil
+ }
+
+ var applicationsToDeploy []qovery.DeployAllRequestApplicationsInner
+
+ for _, application := range applicationList {
+
+ // if commitId is not set, use the deployed commit id
+ applicationCommitId := application.GitRepository.DeployedCommitId
+ if commitId != "" {
+ // commitId is set, use it
+ applicationCommitId = &commitId
+ }
+
+ applicationsToDeploy = append(applicationsToDeploy, qovery.DeployAllRequestApplicationsInner{
+ ApplicationId: application.Id,
+ GitCommitId: applicationCommitId,
+ })
+ }
+
+ req := qovery.DeployAllRequest{
+ Applications: applicationsToDeploy,
+ Databases: nil,
+ Containers: nil,
+ Jobs: nil,
+ }
+
+ return deployAllServices(client, envId, req)
+}
+
+func DeployContainers(client *qovery.APIClient, envId string, containerList []*qovery.ContainerResponse, tag string) error {
+ if len(containerList) == 0 {
+ return nil
+ }
+
+ var containersToDeploy []qovery.DeployAllRequestContainersInner
+ for _, container := range containerList {
+
+ // if tag is not set, use the deployed commit id
+ containerTag := container.Tag
+ if tag != "" {
+ // tag is set, use it
+ containerTag = tag
+ }
+
+ containersToDeploy = append(containersToDeploy, qovery.DeployAllRequestContainersInner{
+ Id: container.Id,
+ ImageTag: &containerTag,
+ })
+ }
+
+ req := qovery.DeployAllRequest{
+ Applications: nil,
+ Databases: nil,
+ Containers: containersToDeploy,
+ Jobs: nil,
+ }
+
+ return deployAllServices(client, envId, req)
+}
+
+func DeployJobs(client *qovery.APIClient, envId string, jobList []*qovery.JobResponse, commitId string, tag string) error {
+ if len(jobList) == 0 {
+ return nil
+ }
+
+ var jobsToDeploy []qovery.DeployAllRequestJobsInner
+
+ for _, job := range jobList {
+
+ var docker = GetJobDocker(job)
+ var image = GetJobImage(job)
+
+ var mCommitId *string
+ var mTag *string
+
+ if docker != nil {
+ mCommitId = docker.GitRepository.DeployedCommitId
+ if commitId != "" {
+ mCommitId = &commitId
+ }
+
+ } else {
+ mTag = &image.Tag
+
+ if tag != "" {
+ mTag = &tag
+ }
+ }
+
+ var jobId = GetJobId(job)
+ jobsToDeploy = append(jobsToDeploy, qovery.DeployAllRequestJobsInner{
+ Id: &jobId,
+ ImageTag: mTag,
+ GitCommitId: mCommitId,
+ })
+ }
+
+ req := qovery.DeployAllRequest{
+ Applications: nil,
+ Databases: nil,
+ Containers: nil,
+ Jobs: jobsToDeploy,
+ }
+
+ return deployAllServices(client, envId, req)
+}
+
+func GetJobDocker(job *qovery.JobResponse) *qovery.JobSourceDockerResponse {
+ if job.CronJobResponse != nil && job.CronJobResponse.Source.BaseJobResponseAllOfSourceOneOf1 != nil {
+ return &job.CronJobResponse.Source.BaseJobResponseAllOfSourceOneOf1.Docker
+ }
+
+ if job.LifecycleJobResponse != nil && job.LifecycleJobResponse.Source.BaseJobResponseAllOfSourceOneOf1 != nil {
+ return &job.LifecycleJobResponse.Source.BaseJobResponseAllOfSourceOneOf1.Docker
+ }
+
+ return nil
+}
+
+func GetJobImage(job *qovery.JobResponse) *qovery.ContainerSource {
+ if job.CronJobResponse != nil && job.CronJobResponse.Source.BaseJobResponseAllOfSourceOneOf != nil {
+ return &job.CronJobResponse.Source.BaseJobResponseAllOfSourceOneOf.Image
+ }
+
+ if job.LifecycleJobResponse != nil && job.LifecycleJobResponse.Source.BaseJobResponseAllOfSourceOneOf != nil {
+ return &job.LifecycleJobResponse.Source.BaseJobResponseAllOfSourceOneOf.Image
+ }
+
+ return nil
+}
+
+func GetJobId(job *qovery.JobResponse) string {
+ if job.CronJobResponse != nil {
+ return job.CronJobResponse.Id
+ }
+ if job.LifecycleJobResponse != nil {
+ return job.LifecycleJobResponse.Id
+ }
+ return ""
+}
+
+func GetJobName(job *qovery.JobResponse) string {
+ if job.CronJobResponse != nil {
+ return job.CronJobResponse.Name
+ }
+ if job.LifecycleJobResponse != nil {
+ return job.LifecycleJobResponse.Name
+ }
+ return ""
+}
+
+func DeployDatabases(client *qovery.APIClient, envId string, databaseList []*qovery.Database) error {
+ if len(databaseList) == 0 {
+ return nil
+ }
+
+ var databasesToDeploy []string
+
+ for _, database := range databaseList {
+ databasesToDeploy = append(databasesToDeploy, database.Id)
+ }
+
+ req := qovery.DeployAllRequest{
+ Applications: nil,
+ Containers: nil,
+ Databases: databasesToDeploy,
+ Jobs: nil,
+ }
+
+ return deployAllServices(client, envId, req)
+}
+
+func DeployHelms(client *qovery.APIClient, envId string, helmList []*qovery.HelmResponse, chartVersion string, chartGitCommitId string, valuesOverrideCommitId string) error {
+ if len(helmList) == 0 {
+ return nil
+ }
+
+ var helmsToDeploy []qovery.DeployAllRequestHelmsInner
+
+ for _, helm := range helmList {
+
+ var gitSource = GetGitSource(helm)
+ var helmRepositorySource = GetHelmRepository(helm)
+
+ if gitSource != nil && helmRepositorySource != nil {
+ return fmt.Errorf("invalid helm")
+ }
+
+ var mCommitId *string
+ var mChartVersion *string
+ var mValuesOverrideCommitId *string
+
+ if gitSource != nil {
+ if chartGitCommitId != "" {
+ mCommitId = &chartGitCommitId
+ }
+ }
+
+ if helmRepositorySource != nil {
+ if chartVersion != "" {
+ mChartVersion = &chartVersion
+ }
+ }
+
+ if valuesOverrideCommitId != "" {
+ mValuesOverrideCommitId = &valuesOverrideCommitId
+ }
+
+ helmsToDeploy = append(helmsToDeploy, qovery.DeployAllRequestHelmsInner{
+ Id: &helm.Id,
+ ChartVersion: mChartVersion,
+ GitCommitId: mCommitId,
+ ValuesOverrideGitCommitId: mValuesOverrideCommitId,
+ })
+ }
+
+ req := qovery.DeployAllRequest{
+ Applications: nil,
+ Databases: nil,
+ Containers: nil,
+ Jobs: nil,
+ Helms: helmsToDeploy,
+ }
+
+ return deployAllServices(client, envId, req)
+}
+
+func GetGitSource(helm *qovery.HelmResponse) *qovery.HelmSourceGitResponse {
+ if helm.Source.HelmResponseAllOfSourceOneOf != nil {
+ return &helm.Source.HelmResponseAllOfSourceOneOf.Git
+ }
+
+ return nil
+}
+
+func GetHelmRepository(helm *qovery.HelmResponse) *qovery.HelmSourceRepositoryResponse {
+ if helm.Source.HelmResponseAllOfSourceOneOf1 != nil {
+ return &helm.Source.HelmResponseAllOfSourceOneOf1.Repository
+ }
+
+ return nil
+}
+
+func DeployTerraforms(client *qovery.APIClient, envId string, terraformList []*qovery.TerraformResponse, commitId string, action *string) error {
+ if len(terraformList) == 0 {
+ return nil
+ }
+
+ // If action is not nil (PLAN, FORCE_UNLOCK, MIGRATE_STATE), use individual API
+ // DeployAllServices only supports PLAN_AND_APPLY
+ if action != nil {
+ for _, terraform := range terraformList {
+ req := qovery.TerraformDeployRequest{
+ Action: *qovery.NewNullableString(action),
+ }
+
+ // Set commit ID if provided
+ if commitId != "" {
+ req.GitCommitId = &commitId
+ }
+
+ _, _, err := client.TerraformActionsAPI.DeployTerraform(context.Background(), terraform.Id).TerraformDeployRequest(req).Execute()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+
+ // If action is null (PLAN_AND_APPLY), use batch DeployAllServices
+ var terraformsToDeploy []qovery.TerraformDeployRequest
+
+ for _, terraform := range terraformList {
+ req := qovery.TerraformDeployRequest{
+ Id: *qovery.NewNullableString(&terraform.Id),
+ }
+
+ // Set commit ID if provided
+ if commitId != "" {
+ req.GitCommitId = &commitId
+ }
+
+ terraformsToDeploy = append(terraformsToDeploy, req)
+ }
+
+ deployReq := qovery.DeployAllRequest{
+ Applications: nil,
+ Databases: nil,
+ Containers: nil,
+ Jobs: nil,
+ Helms: nil,
+ Terraforms: terraformsToDeploy,
+ }
+
+ return deployAllServices(client, envId, deployReq)
+}
+
+func DeleteTerraforms(client *qovery.APIClient, envId string, terraformList []*qovery.TerraformResponse, skipDestroy bool, resourcesOnly bool) error {
+ if len(terraformList) == 0 {
+ return nil
+ }
+
+ for _, terraform := range terraformList {
+ req := client.TerraformMainCallsAPI.DeleteTerraform(context.Background(), terraform.Id)
+
+ if resourcesOnly {
+ req = req.ResourcesOnly(true)
+ }
+
+ if skipDestroy {
+ action := qovery.DELETETERRAFORMACTION_SKIP_DESTROY
+ req = req.ForceTerraformAction(action)
+ }
+
+ _, err := req.Execute()
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func deployAllServices(client *qovery.APIClient, envId string, req qovery.DeployAllRequest) error {
+ _, _, err := client.EnvironmentActionsAPI.DeployAllServices(context.Background(), envId).DeployAllRequest(req).Execute()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func CancelEnvironmentDeployment(client *qovery.APIClient, envId string, watchFlag bool) error {
+ _, _, err := client.EnvironmentActionsAPI.CancelEnvironmentDeployment(context.Background(), envId).Execute()
+
+ if err != nil {
+ return err
+ }
+
+ if watchFlag {
+ WatchEnvironmentWithOptions(envId, qovery.STATEENUM_CANCELED, client, true)
+ }
+
+ return nil
+}
+
+func IsTerminalState(state qovery.StateEnum) bool {
+ return state == qovery.STATEENUM_DEPLOYED || state == qovery.STATEENUM_DELETED ||
+ state == qovery.STATEENUM_STOPPED || state == qovery.STATEENUM_CANCELED ||
+ state == qovery.STATEENUM_READY || state == qovery.STATEENUM_RESTARTED ||
+ strings.HasSuffix(string(state), "ERROR")
+}
+
+func IsTerminalClusterState(state qovery.ClusterStateEnum) bool {
+ return state == qovery.CLUSTERSTATEENUM_DEPLOYED || state == qovery.CLUSTERSTATEENUM_DELETED ||
+ state == qovery.CLUSTERSTATEENUM_STOPPED || state == qovery.CLUSTERSTATEENUM_CANCELED ||
+ state == qovery.CLUSTERSTATEENUM_READY || state == qovery.CLUSTERSTATEENUM_RESTARTED ||
+ state == qovery.CLUSTERSTATEENUM_INVALID_CREDENTIALS || strings.HasSuffix(string(state), "ERROR")
+}
+
+func CancelServiceDeployment(client *qovery.APIClient, envId string, serviceId string, serviceType ServiceType, watchFlag bool) (string, error) {
+ statuses, _, err := client.EnvironmentMainCallsAPI.GetEnvironmentStatuses(context.Background(), envId).Execute()
+
+ if err != nil {
+ return "", err
+ }
+
+ envStatus := statuses.GetEnvironment()
+
+ if IsTerminalState(envStatus.State) {
+ // if the environment is in a terminal state, there is nothing to cancel
+ return "there is no deployment in progress. Nothing to cancel", nil
+ }
+
+ // cancel deployment if the targeted service is a non-terminal state
+ switch serviceType {
+ case ApplicationType:
+ for _, application := range statuses.GetApplications() {
+ if application.Id == serviceId && !IsTerminalState(application.State) {
+ err := CancelEnvironmentDeployment(client, envId, watchFlag)
+ if err != nil {
+ return "", err
+ }
+
+ return "", nil
+ }
+ }
+ case DatabaseType:
+ for _, database := range statuses.GetDatabases() {
+ if database.Id == serviceId && !IsTerminalState(database.State) {
+ err := CancelEnvironmentDeployment(client, envId, watchFlag)
+ if err != nil {
+ return "", err
+ }
+
+ return "", nil
+ }
+ }
+ case ContainerType:
+ for _, container := range statuses.GetContainers() {
+ if container.Id == serviceId && !IsTerminalState(container.State) {
+ err := CancelEnvironmentDeployment(client, envId, watchFlag)
+ if err != nil {
+ return "", err
+ }
+
+ return "", nil
+ }
+ }
+ case JobType:
+ for _, job := range statuses.GetJobs() {
+ if job.Id == serviceId && !IsTerminalState(job.State) {
+ err := CancelEnvironmentDeployment(client, envId, watchFlag)
+ if err != nil {
+ return "", err
+ }
+
+ return "", nil
+ }
+ }
+ case HelmType:
+ for _, helm := range statuses.GetHelms() {
+ if helm.Id == serviceId && !IsTerminalState(helm.State) {
+ err := CancelEnvironmentDeployment(client, envId, watchFlag)
+ if err != nil {
+ return "", err
+ }
+
+ return "", nil
+ }
+ }
+ }
+
+ PrintlnInfo("waiting for previous deployment to be completed...")
+
+ // sleep here to avoid too many requests
+ time.Sleep(5 * time.Second)
+
+ return CancelServiceDeployment(client, envId, serviceId, serviceType, watchFlag)
+}
+
+func ToJobRequest(job qovery.JobResponse) qovery.JobRequest {
+ var docker = GetJobDocker(&job)
+ var image = GetJobImage(&job)
+
+ var sourceImage qovery.JobRequestAllOfSourceImage
+
+ if image != nil {
+ sourceImage = qovery.JobRequestAllOfSourceImage{
+ ImageName: &image.ImageName,
+ Tag: &image.Tag,
+ RegistryId: image.RegistryId,
+ }
+ }
+
+ var sourceDocker qovery.JobRequestAllOfSourceDocker
+
+ if docker != nil {
+ sourceDockerGitRepository := qovery.ApplicationGitRepositoryRequest{
+ Branch: docker.GitRepository.Branch,
+ GitTokenId: docker.GitRepository.GitTokenId,
+ RootPath: docker.GitRepository.RootPath,
+ Url: docker.GitRepository.Url,
+ Provider: docker.GitRepository.Provider,
+ }
+
+ sourceDocker = qovery.JobRequestAllOfSourceDocker{
+ DockerfilePath: docker.DockerfilePath,
+ GitRepository: &sourceDockerGitRepository,
+ }
+ }
+
+ source := qovery.JobRequestAllOfSource{
+ Image: qovery.NullableJobRequestAllOfSourceImage{},
+ Docker: qovery.NullableJobRequestAllOfSourceDocker{},
+ }
+
+ source.Image.Set(&sourceImage)
+ source.Docker.Set(&sourceDocker)
+
+ if job.LifecycleJobResponse != nil {
+ var schedule = qovery.JobRequestAllOfSchedule{
+ OnStart: job.LifecycleJobResponse.Schedule.OnStart,
+ OnStop: job.LifecycleJobResponse.Schedule.OnStop,
+ OnDelete: job.LifecycleJobResponse.Schedule.OnDelete,
+ LifecycleType: job.LifecycleJobResponse.Schedule.LifecycleType,
+ Cronjob: nil,
+ }
+
+ return qovery.JobRequest{
+ Name: job.LifecycleJobResponse.Name,
+ Description: job.LifecycleJobResponse.Description,
+ Cpu: Int32(job.LifecycleJobResponse.Cpu),
+ Memory: Int32(job.LifecycleJobResponse.Memory),
+ MaxNbRestart: job.LifecycleJobResponse.MaxNbRestart,
+ MaxDurationSeconds: job.LifecycleJobResponse.MaxDurationSeconds,
+ AutoPreview: Bool(job.LifecycleJobResponse.AutoPreview),
+ Port: job.LifecycleJobResponse.Port,
+ Source: &source,
+ Healthchecks: job.LifecycleJobResponse.Healthchecks,
+ Schedule: &schedule,
+ AutoDeploy: *qovery.NewNullableBool(job.LifecycleJobResponse.AutoDeploy),
+ }
+ } else {
+ var scheduleCronjob = qovery.JobRequestAllOfScheduleCronjob{
+ Entrypoint: job.CronJobResponse.Schedule.Cronjob.Entrypoint,
+ Arguments: job.CronJobResponse.Schedule.Cronjob.Arguments,
+ ScheduledAt: job.CronJobResponse.Schedule.Cronjob.ScheduledAt,
+ }
+
+ var schedule = qovery.JobRequestAllOfSchedule{
+ OnStart: nil,
+ OnStop: nil,
+ OnDelete: nil,
+ LifecycleType: nil,
+ Cronjob: &scheduleCronjob,
+ }
+
+ return qovery.JobRequest{
+ Name: job.CronJobResponse.Name,
+ Description: job.CronJobResponse.Description,
+ Cpu: Int32(job.CronJobResponse.Cpu),
+ Memory: Int32(job.CronJobResponse.Memory),
+ MaxNbRestart: job.CronJobResponse.MaxNbRestart,
+ MaxDurationSeconds: job.CronJobResponse.MaxDurationSeconds,
+ AutoPreview: Bool(job.CronJobResponse.AutoPreview),
+ Port: job.CronJobResponse.Port,
+ Source: &source,
+ Healthchecks: job.CronJobResponse.Healthchecks,
+ Schedule: &schedule,
+ AutoDeploy: *qovery.NewNullableBool(job.CronJobResponse.AutoDeploy),
+ }
+ }
+}
+
+func GetDuration(startTime time.Time, endTime time.Time) string {
+ duration := endTime.Sub(startTime)
+
+ if duration.Minutes() < 1 {
+ return fmt.Sprintf("%d seconds", int(duration.Seconds()))
+ }
+
+ if duration.Minutes() < 2 {
+ return fmt.Sprintf("%d minute and %d seconds", int(duration.Minutes()), int(duration.Seconds())%60)
+ }
+
+ if duration.Minutes() > 0 && duration.Seconds() == 0 {
+ return fmt.Sprintf("%d minutes", int(duration.Minutes()))
+ }
+
+ return fmt.Sprintf("%d minutes and %d seconds", int(duration.Minutes()), int(duration.Seconds())%60)
+}
diff --git a/utils/random.go b/utils/random.go
new file mode 100644
index 00000000..6631a209
--- /dev/null
+++ b/utils/random.go
@@ -0,0 +1,13 @@
+package utils
+
+import "math/rand"
+
+const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+
+func RandStringBytes(n int) string {
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = letterBytes[rand.Intn(len(letterBytes))]
+ }
+ return string(b)
+}
diff --git a/utils/script_generator.go b/utils/script_generator.go
deleted file mode 100644
index c2c4375e..00000000
--- a/utils/script_generator.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package utils
-
-type Var struct {
- Key string
- Value string
-}
-
-func GenerateExportEnvVarsScript(vars []Var, clusterId string) {
- content := []byte("#!/bin/bash \n")
- for _, variable := range vars {
- line := []byte("export " + variable.Key + "=" + variable.Value + "\n")
- content = append(content, line...)
- }
-
- WriteInFile(clusterId, "script", content)
-}
diff --git a/utils/string.go b/utils/string.go
new file mode 100644
index 00000000..7be62f3c
--- /dev/null
+++ b/utils/string.go
@@ -0,0 +1,7 @@
+package utils
+
+import "strings"
+
+func IsEmptyOrBlank(str string) bool {
+ return len(strings.Trim(str, " ")) == 0
+}
diff --git a/utils/types.go b/utils/types.go
new file mode 100644
index 00000000..d84e5bb0
--- /dev/null
+++ b/utils/types.go
@@ -0,0 +1,5 @@
+package utils
+
+func Int32(v int32) *int32 { return &v }
+
+func Bool(v bool) *bool { return &v }
diff --git a/utils/version.go b/utils/version.go
new file mode 100644
index 00000000..1926cb9b
--- /dev/null
+++ b/utils/version.go
@@ -0,0 +1,4 @@
+package utils
+
+// wil be replaced by CI by the latest git tag
+var Version = "unknown"
diff --git a/variable/verbose.go b/variable/verbose.go
new file mode 100644
index 00000000..b2de6a67
--- /dev/null
+++ b/variable/verbose.go
@@ -0,0 +1,3 @@
+package variable
+
+var Verbose bool