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 Logo + Qovery Logo

[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(`[![Qovery Preview](https://www.qovery.com/images/logo-white.svg)](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