feat(cli): import nhost/cli into ./cli/ (#3506)
Co-authored-by: nhost-build <98952681+nhost-build@users.noreply.github.com> Co-authored-by: szilarddoro <szilarddoro@users.noreply.github.com> Co-authored-by: nunopato <nunopato@users.noreply.github.com> Co-authored-by: Nuno Pato <nunopato@gmail.com> Co-authored-by: Szilárd Dóró <doroszilard@gmail.com> Co-authored-by: Alex Duval <alexduval71@gmail.com> Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com> Co-authored-by: constance-seedstash <123984067+constance-seedstash@users.noreply.github.com> Co-authored-by: onehassan <onehassan@users.noreply.github.com> Co-authored-by: Ibrahim Ahmed <abeahmed2@gmail.com> Co-authored-by: Nestor Manrique <nes.manrique@gmail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Siddhant <94226898+S1D007@users.noreply.github.com> Co-authored-by: dbm03 <dbm03@users.noreply.github.com> Co-authored-by: Rene Cruces <52537668+renecruces@users.noreply.github.com> Co-authored-by: David BM <correodelnino@gmail.com> Co-authored-by: Nikhil Iyer <iyer.h.nikhil@gmail.com>
This commit is contained in:
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -23,8 +23,10 @@ Where `TYPE` is:
|
||||
Where `PKG` is:
|
||||
|
||||
- `ci`: For general changes to the build and/or CI/CD pipeline
|
||||
- `cli`: For changes to the Nhost CLI
|
||||
- `codegen`: For changes to the code generator
|
||||
- `dashboard`: For changes to the Nhost Dashboard
|
||||
- `deps`: For changes to dependencies
|
||||
- `docs`: For changes to the documentation
|
||||
- `examples`: For changes to the examples
|
||||
- `mintlify-openapi`: For changes to the Mintlify OpenAPI tool
|
||||
|
||||
16
.github/workflows/ci_release.yaml
vendored
16
.github/workflows/ci_release.yaml
vendored
@@ -33,6 +33,22 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted project: $PROJECT, version: $VERSION"
|
||||
|
||||
cli:
|
||||
needs: extract-project
|
||||
if: needs.extract-project.outputs.project == 'cli'
|
||||
uses: ./.github/workflows/cli_release.yaml
|
||||
with:
|
||||
NAME: dashboard
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: ${{ needs.extract-project.outputs.version }}
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
dashboard:
|
||||
needs: extract-project
|
||||
if: needs.extract-project.outputs.project == '@nhost/dashboard'
|
||||
|
||||
2
.github/workflows/ci_update_changelog.yaml
vendored
2
.github/workflows/ci_update_changelog.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
project: [dashboard, packages/nhost-js]
|
||||
project: [cli, dashboard, packages/nhost-js]
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
97
.github/workflows/cli_checks.yaml
vendored
Normal file
97
.github/workflows/cli_checks.yaml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: "cli: check and build"
|
||||
on:
|
||||
# pull_request_target:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/cli_checks.yaml'
|
||||
- '.github/workflows/wf_check.yaml'
|
||||
- '.github/workflows/wf_build_artifacts.yaml'
|
||||
- '.github/workflows/cli_test_new_project.yaml'
|
||||
|
||||
# common build
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'nixops/**'
|
||||
- 'build/**'
|
||||
|
||||
# common go
|
||||
- '.golangci.yaml'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'vendor/**'
|
||||
|
||||
# cli
|
||||
- 'cli/**'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo "github.event_name: ${{ github.event_name }}"
|
||||
echo "github.event.pull_request.author_association: ${{ github.event.pull_request.author_association }}"
|
||||
- name: "This task will run and fail if user has no permissions and label safe_to_test isn't present"
|
||||
if: "github.event_name == 'pull_request_target' && ! ( contains(github.event.pull_request.labels.*.name, 'safe_to_test') || contains(fromJson('[\"OWNER\", \"MEMBER\", \"COLLABORATOR\"]'), github.event.pull_request.author_association) )"
|
||||
run: |
|
||||
exit 1
|
||||
|
||||
tests:
|
||||
uses: ./.github/workflows/wf_check.yaml
|
||||
needs:
|
||||
- check-permissions
|
||||
with:
|
||||
NAME: cli
|
||||
PATH: cli
|
||||
GIT_REF: ${{ github.sha }}
|
||||
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||
|
||||
build_artifacts:
|
||||
uses: ./.github/workflows/wf_build_artifacts.yaml
|
||||
needs:
|
||||
- check-permissions
|
||||
with:
|
||||
NAME: cli
|
||||
PATH: cli
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
||||
DOCKER: true
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
|
||||
test_cli_build:
|
||||
uses: ./.github/workflows/cli_test_new_project.yaml
|
||||
needs:
|
||||
- check-permissions
|
||||
- build_artifacts
|
||||
with:
|
||||
NAME: cli
|
||||
PATH: cli
|
||||
GIT_REF: ${{ github.sha }}
|
||||
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||
|
||||
remove_label:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-permissions
|
||||
steps:
|
||||
- uses: actions-ecosystem/action-remove-labels@v1
|
||||
with:
|
||||
labels: |
|
||||
safe_to_test
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe_to_test')
|
||||
143
.github/workflows/cli_release.yaml
vendored
Normal file
143
.github/workflows/cli_release.yaml
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: "cli: release"
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
NAME:
|
||||
required: true
|
||||
type: string
|
||||
GIT_REF:
|
||||
required: true
|
||||
type: string
|
||||
VERSION:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID:
|
||||
required: true
|
||||
NIX_CACHE_PUB_KEY:
|
||||
required: true
|
||||
NIX_CACHE_PRIV_KEY:
|
||||
required: true
|
||||
GH_PAT:
|
||||
required: true
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build_artifacts:
|
||||
uses: ./.github/workflows/wf_build_artifacts.yaml
|
||||
with:
|
||||
NAME: cli
|
||||
PATH: cli
|
||||
GIT_REF: ${{ inputs.GIT_REF }}
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
DOCKER: true
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
|
||||
push-docker:
|
||||
uses: ./.github/workflows/wf_docker_push_image.yaml
|
||||
needs:
|
||||
- build_artifacts
|
||||
with:
|
||||
NAME: cli
|
||||
PATH: cli
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
build-multiplatform:
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli
|
||||
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 180
|
||||
|
||||
steps:
|
||||
- name: "Check out repository"
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.GIT_REF }}
|
||||
|
||||
- name: Configure aws
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ inputs.NAME }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
|
||||
- name: Compute common env vars
|
||||
id: vars
|
||||
run: |
|
||||
echo "VERSION=$(make get-version VER=${{ inputs.VERSION }})" >> $GITHUB_OUTPUT
|
||||
ARCH=$([ "${{ runner.arch }}" == "X64" ] && echo "x86_64" || echo "aarch64")
|
||||
echo "ARCH=${ARCH}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "Build artifact"
|
||||
run: |
|
||||
make build-multiplatform
|
||||
|
||||
- name: "Upload assets"
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
export VERSION=${{ steps.vars.outputs.VERSION }}
|
||||
|
||||
mkdir upload
|
||||
|
||||
tar cvzf upload/cli-$VERSION-darwin-amd64.tar.gz -C result/darwin/amd64 cli
|
||||
tar cvzf upload/cli-$VERSION-darwin-arm64.tar.gz -C result/darwin/arm64 cli
|
||||
tar cvzf upload/cli-$VERSION-linux-amd64.tar.gz -C result/linux/amd64 cli
|
||||
tar cvzf upload/cli-$VERSION-linux-arm64.tar.gz -C result/linux/arm64 cli
|
||||
|
||||
cd upload
|
||||
find . -type f -exec sha256sum {} + > ../checksums.txt
|
||||
cd ..
|
||||
|
||||
cat checksums.txt
|
||||
|
||||
gh release upload \
|
||||
--clobber "${{ github.ref_name }}" \
|
||||
./upload/* checksums.txt
|
||||
|
||||
- name: "Cache build"
|
||||
run: |
|
||||
nix store sign --key-file <(echo "${{ secrets.NIX_CACHE_PRIV_KEY }}") --all
|
||||
find /nix/store -maxdepth 1 -name "*-*" -type d | xargs -n 25 nix copy --to s3://nhost-nix-cache\?region=eu-central-1
|
||||
if: always()
|
||||
116
.github/workflows/cli_test_new_project.yaml
vendored
Normal file
116
.github/workflows/cli_test_new_project.yaml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
NAME:
|
||||
type: string
|
||||
required: true
|
||||
PATH:
|
||||
type: string
|
||||
required: true
|
||||
GIT_REF:
|
||||
type: string
|
||||
required: false
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID:
|
||||
required: true
|
||||
NIX_CACHE_PUB_KEY:
|
||||
required: true
|
||||
NIX_CACHE_PRIV_KEY:
|
||||
required: true
|
||||
NHOST_PAT:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.PATH }}
|
||||
|
||||
env:
|
||||
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: "Check out repository"
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.GIT_REF }}
|
||||
|
||||
- name: Collect Workflow Telemetry
|
||||
uses: catchpoint/workflow-telemetry-action@v2
|
||||
with:
|
||||
comment_on_pr: false
|
||||
|
||||
- name: Configure aws
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ inputs.NAME }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
|
||||
- name: "Get artifacts"
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: ~/artifacts
|
||||
|
||||
- name: "Inspect artifacts"
|
||||
run: find ~/artifacts
|
||||
|
||||
- name: Load docker image
|
||||
run: |
|
||||
skopeo copy --insecure-policy \
|
||||
dir:/home/runner/artifacts/cli-docker-image-x86_64-0.0.0-dev \
|
||||
docker-daemon:cli:0.0.0-dev
|
||||
|
||||
- name: "Create a new project"
|
||||
run: |
|
||||
export NHOST_DOMAIN=staging.nhost.run
|
||||
export NHOST_CONFIGSERVER_IMAGE=cli:0.0.0-dev
|
||||
|
||||
unzip /home/runner/artifacts/cli-artifact-x86_64-0.0.0-dev/result.zip
|
||||
|
||||
mkdir new-project
|
||||
cd new-project
|
||||
/home/runner/_work/nhost/nhost/cli/result/bin/cli login --pat ${{ secrets.NHOST_PAT }}
|
||||
/home/runner/_work/nhost/nhost/cli/result/bin/cli init
|
||||
/home/runner/_work/nhost/nhost/cli/result/bin/cli up --down-on-error
|
||||
/home/runner/_work/nhost/nhost/cli/result/bin/cli down --volumes
|
||||
|
||||
- name: "Cache build"
|
||||
run: |
|
||||
nix store sign --key-file <(echo "${{ secrets.NIX_CACHE_PRIV_KEY }}") --all
|
||||
find /nix/store -maxdepth 1 -name "*-*" -type d | xargs -n 25 nix copy --to s3://nhost-nix-cache\?region=eu-central-1
|
||||
if: always()
|
||||
|
||||
7
.github/workflows/dashboard_release.yaml
vendored
7
.github/workflows/dashboard_release.yaml
vendored
@@ -83,20 +83,19 @@ jobs:
|
||||
- push-docker
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: nhost/cli
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
|
||||
- name: Bump version in source code
|
||||
run: |
|
||||
find . -type f -exec sed -i 's/"nhost\/dashboard:[^"]*"/"nhost\/dashboard:${{ inputs.VERSION }}"/g' {} +
|
||||
find cli -type f -exec sed -i 's/"nhost\/dashboard:[^"]*"/"nhost\/dashboard:${{ inputs.VERSION }}"/g' {} +
|
||||
|
||||
- name: "Create Pull Request"
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
title: "chore: bump nhost/dashboard to ${{ inputs.VERSION }}"
|
||||
title: "chore(cli): bump nhost/dashboard to ${{ inputs.VERSION }}"
|
||||
commit-message: "chore: bump nhost/dashboard to ${{ inputs.VERSION }}"
|
||||
committer: GitHub <noreply@github.com>
|
||||
author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
|
||||
|
||||
5
.github/workflows/wf_check.yaml
vendored
5
.github/workflows/wf_check.yaml
vendored
@@ -18,6 +18,8 @@ on:
|
||||
required: true
|
||||
NIX_CACHE_PRIV_KEY:
|
||||
required: true
|
||||
NHOST_PAT:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
@@ -28,6 +30,9 @@ jobs:
|
||||
run:
|
||||
working-directory: ${{ inputs.PATH }}
|
||||
|
||||
env:
|
||||
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -71,3 +71,5 @@ result
|
||||
.vitest
|
||||
|
||||
.claude
|
||||
|
||||
letsencrypt/*
|
||||
|
||||
@@ -54,8 +54,12 @@ get-version: ## Return version
|
||||
@echo $(VERSION)
|
||||
|
||||
|
||||
.PHONY: _check-pre
|
||||
_check-pre: ## Pre-checks before running nix flake check
|
||||
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run nix flake check
|
||||
check: _check-pre ## Run nix flake check
|
||||
nix build \
|
||||
--print-build-logs \
|
||||
.\#checks.$(ARCH)-$(OS).$(NAME)
|
||||
|
||||
20
cli/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
20
cli/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
## Description
|
||||
<!--
|
||||
Use one of the following title prefix to categorize the pull request:
|
||||
feat: mark this pull request as a feature
|
||||
fix: mark this pull request as a bug fix
|
||||
chore: mark this pull request as a maintenance item
|
||||
|
||||
To auto merge this pull request when it was approved
|
||||
by another member of the organization: set the label `auto-merge`
|
||||
-->
|
||||
## Problem
|
||||
A short description of the problem this PR is addressing.
|
||||
|
||||
## Solution
|
||||
A short description of the chosen method to resolve the problem
|
||||
with an overview of the logic and implementation details when needed.
|
||||
|
||||
## Notes
|
||||
Other notes that you want to share but do not fit into _Problem_ or _Solution_.
|
||||
|
||||
36
cli/.github/cert.sh
vendored
Executable file
36
cli/.github/cert.sh
vendored
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p /tmp/letsencrypt
|
||||
|
||||
echo "Generating SSL certificate for hostnames: local.nhost.run, local.graphql.nhost.run, local.auth.nhost.run, local.storage.nhost.run, local.functions.nhost.run, local.mail.nhost.run"
|
||||
docker run --rm \
|
||||
--name certbot \
|
||||
-e AWS_ACCESS_KEY_ID \
|
||||
-e AWS_SECRET_ACCESS_KEY \
|
||||
-e AWS_SESSION_TOKEN \
|
||||
-e AWS_REGION \
|
||||
-v /tmp/letsencrypt:/etc/letsencrypt \
|
||||
-v /tmp/letsencrypt:/var/lib/letsencrypt \
|
||||
certbot/dns-route53 certonly --dns-route53 --dns-route53-propagation-seconds 60 \
|
||||
-d local.auth.nhost.run \
|
||||
-d local.dashboard.nhost.run \
|
||||
-d local.db.nhost.run \
|
||||
-d local.functions.nhost.run \
|
||||
-d local.graphql.nhost.run \
|
||||
-d local.hasura.nhost.run \
|
||||
-d local.mailhog.nhost.run \
|
||||
-d local.storage.nhost.run \
|
||||
-d *.auth.local.nhost.run \
|
||||
-d *.dashboard.local.nhost.run \
|
||||
-d *.db.local.nhost.run \
|
||||
-d *.functions.local.nhost.run \
|
||||
-d *.graphql.local.nhost.run \
|
||||
-d *.hasura.local.nhost.run \
|
||||
-d *.mailhog.local.nhost.run \
|
||||
-d *.storage.local.nhost.run \
|
||||
-m 'admin@nhost.io' --non-interactive --agree-tos --server https://acme-v02.api.letsencrypt.org/directory
|
||||
|
||||
sudo cp /tmp/letsencrypt/live/local.db.nhost.run/fullchain.pem ssl/.ssl/
|
||||
sudo cp /tmp/letsencrypt/live/local.db.nhost.run/privkey.pem ssl/.ssl/
|
||||
8
cli/.github/labeler.yml
vendored
Normal file
8
cli/.github/labeler.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
labels:
|
||||
'feature':
|
||||
- '^(?i:feat)'
|
||||
- '^(?i:feature)'
|
||||
'fix':
|
||||
- '^(?i:fix)'
|
||||
'chore':
|
||||
- '^(?i:chore)'
|
||||
39
cli/.github/release-drafter.yml
vendored
Normal file
39
cli/.github/release-drafter.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name-template: 'v$RESOLVED_VERSION'
|
||||
tag-template: 'v$RESOLVED_VERSION'
|
||||
categories:
|
||||
- title: '🚀 Features'
|
||||
label: 'feature'
|
||||
- title: '🐛 Bug Fixes'
|
||||
label: 'fix'
|
||||
- title: '🧰 Maintenance'
|
||||
label: 'chore'
|
||||
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
|
||||
change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
|
||||
version-resolver:
|
||||
major:
|
||||
labels:
|
||||
- 'major'
|
||||
minor:
|
||||
labels:
|
||||
- 'minor'
|
||||
patch:
|
||||
labels:
|
||||
- 'patch'
|
||||
default: patch
|
||||
autolabeler:
|
||||
- label: 'feature'
|
||||
title:
|
||||
- '/^feat/i'
|
||||
- '/^feature/i'
|
||||
- label: 'fix'
|
||||
title:
|
||||
- '/^fix/i'
|
||||
- label: 'chore'
|
||||
title:
|
||||
- '/^chore/i'
|
||||
prerelease: true
|
||||
template: |
|
||||
## Changes
|
||||
|
||||
$CHANGES
|
||||
|
||||
16
cli/.github/stale.yml
vendored
Normal file
16
cli/.github/stale.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Configuration for probot-stale - https://github.com/probot/stale
|
||||
|
||||
daysUntilStale: 180
|
||||
daysUntilClose: 7
|
||||
limitPerRun: 30
|
||||
onlyLabels: []
|
||||
exemptLabels: []
|
||||
|
||||
exemptProjects: false
|
||||
exemptMilestones: false
|
||||
exemptAssignees: false
|
||||
staleLabel: stale
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
17
cli/.github/workflows/assign_labels.yml
vendored
Normal file
17
cli/.github/workflows/assign_labels.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# this workflow will run on all pull requests opened but in the context of the base of the pull request.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
name: "assign labels"
|
||||
jobs:
|
||||
# labeler will label pull requests based on their title.
|
||||
# the configuration is at .github/labeler.yml.
|
||||
label_pull_request:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Label Pull Request
|
||||
uses: jimschubert/labeler-action@v2
|
||||
with:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
53
cli/.github/workflows/build-cert-weekly.yaml.disabled
vendored
Normal file
53
cli/.github/workflows/build-cert-weekly.yaml.disabled
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: "build certificate weekly"
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure aws
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::796351718684:role/github-actions-nhost-cli
|
||||
aws-region: eu-central-1
|
||||
|
||||
- name: fetch let's encrypt cert
|
||||
id: certs
|
||||
run: |
|
||||
.github/cert.sh
|
||||
|
||||
echo "CERT_FULL_CHAIN<<EOF" >> $GITHUB_OUTPUT
|
||||
sudo cat /tmp/letsencrypt/live/local.db.nhost.run/fullchain.pem >> "$GITHUB_OUTPUT"
|
||||
echo EOF >> $GITHUB_OUTPUT
|
||||
|
||||
echo "CERT_PRIV_KEY<<EOF" >> $GITHUB_OUTPUT
|
||||
sudo cat /tmp/letsencrypt/live/local.db.nhost.run/privkey.pem >> "$GITHUB_OUTPUT"
|
||||
echo EOF >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
- uses: hmanzur/actions-set-secret@v2.0.0
|
||||
with:
|
||||
name: 'CERT_FULL_CHAIN'
|
||||
value: "${{ steps.certs.outputs.CERT_FULL_CHAIN }}"
|
||||
repository: nhost/cli
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
|
||||
- uses: hmanzur/actions-set-secret@v2.0.0
|
||||
with:
|
||||
name: 'CERT_PRIV_KEY'
|
||||
value: "${{ steps.certs.outputs.CERT_PRIV_KEY }}"
|
||||
repository: nhost/cli
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
27
cli/.github/workflows/checks.yaml
vendored
Normal file
27
cli/.github/workflows/checks.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: "check and build"
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
uses: ./.github/workflows/wf_check.yaml
|
||||
secrets:
|
||||
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||
|
||||
build_artifacts:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
GOOS: ["darwin", "linux"]
|
||||
GOARCH: ["amd64", "arm64"]
|
||||
uses: ./.github/workflows/wf_build_artifacts.yaml
|
||||
with:
|
||||
GOOS: ${{ matrix.GOOS }}
|
||||
GOARCH: ${{ matrix.GOARCH }}
|
||||
VERSION: ${{ github.sha }}
|
||||
secrets:
|
||||
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||
56
cli/.github/workflows/codeql-analysis.yml
vendored
Normal file
56
cli/.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push: {}
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: '20 23 * * 3'
|
||||
|
||||
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@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
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@v2
|
||||
|
||||
# ℹ️ 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@v2
|
||||
27
cli/.github/workflows/gen_ai_review.yaml
vendored
Normal file
27
cli/.github/workflows/gen_ai_review.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: "gen: AI review"
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, ready_for_review]
|
||||
issue_comment:
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
if: ${{ github.event.sender.type != 'Bot' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
name: Run pr agent on every pull request, respond to user comments
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: Codium-ai/pr-agent@v0.29
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
config.max_model_tokens: 100000
|
||||
config.model: "anthropic/claude-sonnet-4-20250514"
|
||||
config.model_turbo: "anthropic/claude-sonnet-4-20250514"
|
||||
ignore.glob: "['vendor/**','**/client_gen.go','**/models_gen.go','**/generated.go','**/*.gen.go']"
|
||||
91
cli/.github/workflows/gen_schedule_update_deps.yaml
vendored
Normal file
91
cli/.github/workflows/gen_schedule_update_deps.yaml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: "gen: update depenendencies"
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 1 2,5,8,11 *'
|
||||
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure aws
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: nixbuild/nix-quick-install-action@v26
|
||||
with:
|
||||
nix_version: 2.16.2
|
||||
nix_conf: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
|
||||
- name: Cache nix store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /nix
|
||||
key: nix-update-deps-${{ hashFiles('flakes.nix', 'flake.lock') }}
|
||||
|
||||
- name: Update nix flakes
|
||||
run: nix flake update
|
||||
|
||||
- uses: shaunco/ssh-agent@git-repo-mapping
|
||||
with:
|
||||
ssh-private-key: |
|
||||
${{ secrets.NHOST_BE_DEPLOY_SSH_PRIVATE_KEY}}
|
||||
repo-mappings: |
|
||||
github.com/nhost/be
|
||||
|
||||
- name: Update golang dependencies
|
||||
run: |
|
||||
export GOPRIVATE=github.com/nhost/be
|
||||
nix develop -c bash -c "
|
||||
go mod tidy
|
||||
go get -u $(cat go.mod | grep nhost\/be | tr ' ' '@') ./...
|
||||
go mod tidy
|
||||
go mod vendor
|
||||
"
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: Update dependencies
|
||||
committer: GitHub <noreply@github.com>
|
||||
author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
|
||||
signoff: false
|
||||
branch: automated/update-deps
|
||||
delete-branch: true
|
||||
title: '[Scheduled] Update dependencies'
|
||||
body: |
|
||||
Dependencies updated
|
||||
|
||||
Note - If you see this PR and the checks haven't run, close and reopen the PR. See https://github.com/peter-evans/create-pull-request/blob/main/docs/concepts-guidelines.md#triggering-further-workflow-runs
|
||||
labels: |
|
||||
dependencies
|
||||
draft: false
|
||||
|
||||
- name: "Cache nix store on s3"
|
||||
run: |
|
||||
echo ${{ secrets.NIX_CACHE_PRIV_KEY }} > cache-priv-key.pem
|
||||
nix build .\#devShells.x86_64-linux.default
|
||||
nix store sign --key-file cache-priv-key.pem --all
|
||||
nix copy --to s3://nhost-nix-cache\?region=eu-central-1 .\#devShells.x86_64-linux.default
|
||||
|
||||
- run: rm cache-priv-key.pem
|
||||
if: always()
|
||||
35
cli/.github/workflows/release.yaml
vendored
Normal file
35
cli/.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: "release"
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
uses: ./.github/workflows/wf_check.yaml
|
||||
secrets:
|
||||
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||
|
||||
build_artifacts:
|
||||
strategy:
|
||||
matrix:
|
||||
GOOS: ["darwin", "linux"]
|
||||
GOARCH: ["amd64", "arm64"]
|
||||
uses: ./.github/workflows/wf_build_artifacts.yaml
|
||||
with:
|
||||
GOOS: ${{ matrix.GOOS }}
|
||||
GOARCH: ${{ matrix.GOARCH }}
|
||||
VERSION: ${{ github.ref_name }}
|
||||
secrets:
|
||||
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||
|
||||
publish:
|
||||
uses: ./.github/workflows/wf_publish.yaml
|
||||
needs:
|
||||
- tests
|
||||
- build_artifacts
|
||||
with:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
17
cli/.github/workflows/release_drafter.yml
vendored
Normal file
17
cli/.github/workflows/release_drafter.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: "release drafter"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
# draft your next release notes as pull requests are merged into "master"
|
||||
# the configuration is at /.github/release-drafter.yml.
|
||||
update_release_draft:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@v5
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
89
cli/.github/workflows/wf_build_artifacts.yaml
vendored
Normal file
89
cli/.github/workflows/wf_build_artifacts.yaml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
GIT_REF:
|
||||
type: string
|
||||
required: false
|
||||
VERSION:
|
||||
type: string
|
||||
required: true
|
||||
GOOS:
|
||||
type: string
|
||||
required: true
|
||||
GOARCH:
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
NHOST_PAT:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: "Check out repository"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.GIT_REF }}
|
||||
submodules: true
|
||||
|
||||
- uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.22.3/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
|
||||
|
||||
- name: Compute common env vars
|
||||
id: vars
|
||||
run: |
|
||||
echo "VERSION=$(make get-version VERSION=${{ inputs.VERSION }})" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "Build artifact"
|
||||
run: |
|
||||
make build ARCH=${{ inputs.GOARCH }} OS=${{ inputs.GOOS }}
|
||||
find -L result -type f -exec cp {} nhost-cli \;
|
||||
|
||||
- name: "Push artifact to artifact repository"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-${{ steps.vars.outputs.VERSION }}-${{ inputs.GOOS }}-${{ inputs.GOARCH }}
|
||||
path: nhost-cli
|
||||
retention-days: 7
|
||||
|
||||
- name: "Build docker-image"
|
||||
run: |
|
||||
make build-docker-image ARCH=${{ inputs.GOARCH }}
|
||||
if: ${{ ( inputs.GOOS == 'linux' ) }}
|
||||
|
||||
- name: "Create a new project"
|
||||
run: |
|
||||
export NHOST_DOMAIN=staging.nhost.run
|
||||
export NHOST_CONFIGSERVER_IMAGE=nhost/cli:${{ steps.vars.outputs.VERSION }}
|
||||
|
||||
mkdir new-project
|
||||
cd new-project
|
||||
../nhost-cli login --pat ${{ secrets.NHOST_PAT }}
|
||||
../nhost-cli init
|
||||
../nhost-cli up --down-on-error
|
||||
../nhost-cli down
|
||||
if: ${{ ( inputs.GOOS == 'linux' && inputs.GOARCH == 'amd64' ) }}
|
||||
|
||||
- name: "Push docker-image to artifact repository"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-docker-image-${{ steps.vars.outputs.VERSION }}-${{ inputs.GOOS }}-${{ inputs.GOARCH }}
|
||||
path: result
|
||||
retention-days: 7
|
||||
if: ${{ ( inputs.GOOS == 'linux' ) }}
|
||||
42
cli/.github/workflows/wf_check.yaml
vendored
Normal file
42
cli/.github/workflows/wf_check.yaml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
GIT_REF:
|
||||
type: string
|
||||
required: false
|
||||
secrets:
|
||||
NHOST_PAT:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: "Check out repository"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.GIT_REF }}
|
||||
submodules: true
|
||||
|
||||
- uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.22.3/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
|
||||
|
||||
- name: "Run checks"
|
||||
run: |
|
||||
export NHOST_PAT=${{ secrets.NHOST_PAT }}
|
||||
make check
|
||||
93
cli/.github/workflows/wf_publish.yaml
vendored
Normal file
93
cli/.github/workflows/wf_publish.yaml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
|
||||
name: release
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: "Check out repository"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.GIT_REF }}
|
||||
submodules: true
|
||||
|
||||
- name: Compute common env vars
|
||||
id: vars
|
||||
run: |
|
||||
echo "VERSION=$(make get-version VERSION=${{ inputs.VERSION }})" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "Get artifacts"
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ~/artifacts
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Upload docker images
|
||||
shell: bash
|
||||
run: |
|
||||
export VERSION=${{ steps.vars.outputs.VERSION }}
|
||||
export CONTAINER_NAME=nhost/cli
|
||||
|
||||
skopeo copy --insecure-policy \
|
||||
dir:/home/runner/artifacts/cli-docker-image-$VERSION-linux-amd64 \
|
||||
docker-daemon:$CONTAINER_NAME:$VERSION-amd64
|
||||
docker push $CONTAINER_NAME:$VERSION-amd64
|
||||
|
||||
skopeo copy --insecure-policy \
|
||||
dir:/home/runner/artifacts/cli-docker-image-$VERSION-linux-arm64 \
|
||||
docker-daemon:$CONTAINER_NAME:$VERSION-arm64
|
||||
docker push $CONTAINER_NAME:$VERSION-arm64
|
||||
|
||||
docker manifest create \
|
||||
$CONTAINER_NAME:$VERSION \
|
||||
--amend $CONTAINER_NAME:$VERSION-amd64 \
|
||||
--amend $CONTAINER_NAME:$VERSION-arm64
|
||||
docker manifest push $CONTAINER_NAME:$VERSION
|
||||
|
||||
- name: Upload assets
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
export VERSION=${{ steps.vars.outputs.VERSION }}
|
||||
|
||||
mkdir upload
|
||||
|
||||
find ~/artifacts -type f -name "nhost-cli" -exec bash -c 'chmod +x "$0" && mv "$0" "${0//nhost-cli/cli}"' {} \;
|
||||
|
||||
tar cvzf upload/cli-$VERSION-darwin-amd64.tar.gz -C ~/artifacts/cli-$VERSION-darwin-amd64 cli
|
||||
tar cvzf upload/cli-$VERSION-darwin-arm64.tar.gz -C ~/artifacts/cli-$VERSION-darwin-arm64 cli
|
||||
tar cvzf upload/cli-$VERSION-linux-amd64.tar.gz -C ~/artifacts/cli-$VERSION-linux-amd64 cli
|
||||
tar cvzf upload/cli-$VERSION-linux-arm64.tar.gz -C ~/artifacts/cli-$VERSION-linux-arm64 cli
|
||||
|
||||
cd upload
|
||||
find . -type f -exec sha256sum {} + > ../checksums.txt
|
||||
cd ..
|
||||
|
||||
cat checksums.txt
|
||||
|
||||
gh release upload \
|
||||
--clobber "${{ github.ref_name }}" \
|
||||
./upload/* checksums.txt
|
||||
29
cli/Makefile
Normal file
29
cli/Makefile
Normal file
@@ -0,0 +1,29 @@
|
||||
ROOT_DIR?=$(abspath ../)
|
||||
include $(ROOT_DIR)/build/makefiles/general.makefile
|
||||
|
||||
|
||||
.PHONY: _check-pre
|
||||
_check-pre:
|
||||
@sed -i 's/$$NHOST_PAT/$(NHOST_PAT)/' get_access_token.sh
|
||||
|
||||
|
||||
.PHONY: _dev-env-up
|
||||
_dev-env-up:
|
||||
@echo "Nothing to do"
|
||||
|
||||
|
||||
.PHONY: _dev-env-down
|
||||
_dev-env-down:
|
||||
@echo "Nothing to do"
|
||||
|
||||
|
||||
.PHONY: _dev-env-build
|
||||
_dev-env-build:
|
||||
@echo "Nothing to do"
|
||||
|
||||
|
||||
.PHONY: build-multiplatform
|
||||
build-multiplatform: ## Build cli for all supported platforms
|
||||
nix build \
|
||||
--print-build-logs \
|
||||
.\#packages.$(ARCH)-$(OS).cli-multiplatform
|
||||
82
cli/README.md
Normal file
82
cli/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
<div align="center">
|
||||
<h1 style="font-size: 3em; font-weight: bold;">Nhost CLI</h1>
|
||||
</div>
|
||||
|
||||
[Nhost](http://nhost.io) is an open-source Firebase alternative with GraphQL.
|
||||
|
||||
The Nhost CLI is used to set up a local development environment. This environment will automatically track database migrations and Hasura metadata.
|
||||
|
||||
It's recommended to use the Nhost CLI and the [Nhost GitHub Integration](https://docs.nhost.io/platform/github-integration) to develop locally and automatically deploy changes to production with a git-based workflow (similar to Netlify & Vercel).
|
||||
|
||||
## Services
|
||||
|
||||
- [Nhost Dashboard](https://github.com/nhost/nhost/tree/main/dashboard)
|
||||
- [Postgres Database](https://www.postgresql.org/)
|
||||
- [Hasura's GraphQL Engine](https://github.com/hasura/graphql-engine)
|
||||
- [Hasura Auth](https://github.com/nhost/hasura-auth)
|
||||
- [Hasura Storage](https://github.com/nhost/hasura-storage)
|
||||
- [Nhost Serverless Functions](https://github.com/nhost/functions)
|
||||
- [Minio S3](https://github.com/minio/minio)
|
||||
- [Mailhog](https://github.com/mailhog/MailHog)
|
||||
|
||||
## Get Started
|
||||
|
||||
### Install the Nhost CLI
|
||||
|
||||
```bash
|
||||
sudo curl -L https://raw.githubusercontent.com/nhost/nhost/main/cli/get.sh | bash
|
||||
```
|
||||
|
||||
### Initialize a project
|
||||
|
||||
```bash
|
||||
nhost init
|
||||
```
|
||||
|
||||
### Initialize a project with a remote project as a starting point
|
||||
|
||||
```bash
|
||||
nhost init --remote
|
||||
```
|
||||
|
||||
### Start the development environment
|
||||
|
||||
```bash
|
||||
nhost up
|
||||
```
|
||||
|
||||
### Use the Nhost Dashboard
|
||||
|
||||
```bash
|
||||
nhost up --ui nhost
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Get started with Nhost CLI (longer version)](https://docs.nhost.io/platform/overview/get-started-with-nhost-cli)
|
||||
- [Nhost CLI](https://docs.nhost.io/platform/cli)
|
||||
- [Reference](https://docs.nhost.io/reference/cli)
|
||||
|
||||
## Build from Source
|
||||
|
||||
Make sure you have [Go](https://golang.org/doc/install) 1.18 or later installed.
|
||||
|
||||
The source code includes a self-signed certificate for testing purposes. Nhost workers with configured access to AWS may use the `cert.sh` script to generate a real certificate from Let's Encrypt.
|
||||
|
||||
```bash
|
||||
go build -o /usr/local/bin/nhost
|
||||
```
|
||||
This will build the binary available as the `nhost` command in the terminal.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- [curl](https://curl.se/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- MacOS
|
||||
- Linux
|
||||
- Windows WSL2
|
||||
49
cli/cert.sh
Executable file
49
cli/cert.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
certbot certonly \
|
||||
-v \
|
||||
--dns-route53 \
|
||||
-d local.auth.nhost.run \
|
||||
-d local.dashboard.nhost.run \
|
||||
-d local.db.nhost.run \
|
||||
-d local.functions.nhost.run \
|
||||
-d local.graphql.nhost.run \
|
||||
-d local.hasura.nhost.run \
|
||||
-d local.mailhog.nhost.run \
|
||||
-d local.storage.nhost.run \
|
||||
-m 'admin@nhost.io' \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
--server https://acme-v02.api.letsencrypt.org/directory \
|
||||
--logs-dir letsencrypt \
|
||||
--config-dir letsencrypt \
|
||||
--work-dir letsencrypt
|
||||
|
||||
cp letsencrypt/live/local.auth.nhost.run/fullchain.pem ssl/.ssl/local-fullchain.pem
|
||||
cp letsencrypt/live/local.auth.nhost.run/privkey.pem ssl/.ssl/local-privkey.pem
|
||||
|
||||
certbot certonly \
|
||||
-v \
|
||||
--manual \
|
||||
--preferred-challenges dns \
|
||||
-d *.auth.local.nhost.run \
|
||||
-d *.dashboard.local.nhost.run \
|
||||
-d *.db.local.nhost.run \
|
||||
-d *.functions.local.nhost.run \
|
||||
-d *.graphql.local.nhost.run \
|
||||
-d *.hasura.local.nhost.run \
|
||||
-d *.mailhog.local.nhost.run \
|
||||
-d *.storage.local.nhost.run \
|
||||
-m 'admin@nhost.io' \
|
||||
--agree-tos \
|
||||
--server https://acme-v02.api.letsencrypt.org/directory \
|
||||
--logs-dir letsencrypt \
|
||||
--config-dir letsencrypt \
|
||||
--work-dir letsencrypt
|
||||
|
||||
cp letsencrypt/live/auth.local.nhost.run/fullchain.pem ssl/.ssl/sub-fullchain.pem
|
||||
cp letsencrypt/live/auth.local.nhost.run/privkey.pem ssl/.ssl/sub-privkey.pem
|
||||
|
||||
rm -rf letsencrypt
|
||||
126
cli/clienv/clienv.go
Normal file
126
cli/clienv/clienv.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package clienv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/nhost/nhost/cli/nhostclient"
|
||||
"github.com/nhost/nhost/cli/nhostclient/graphql"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func sanitizeName(name string) string {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||
return strings.ToLower(re.ReplaceAllString(name, ""))
|
||||
}
|
||||
|
||||
type CliEnv struct {
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
Path *PathStructure
|
||||
authURL string
|
||||
graphqlURL string
|
||||
branch string
|
||||
nhclient *nhostclient.Client
|
||||
nhpublicclient *nhostclient.Client
|
||||
projectName string
|
||||
localSubdomain string
|
||||
}
|
||||
|
||||
func New(
|
||||
stdout io.Writer,
|
||||
stderr io.Writer,
|
||||
path *PathStructure,
|
||||
authURL string,
|
||||
graphqlURL string,
|
||||
branch string,
|
||||
projectName string,
|
||||
localSubdomain string,
|
||||
) *CliEnv {
|
||||
return &CliEnv{
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
Path: path,
|
||||
authURL: authURL,
|
||||
graphqlURL: graphqlURL,
|
||||
branch: branch,
|
||||
nhclient: nil,
|
||||
nhpublicclient: nil,
|
||||
projectName: projectName,
|
||||
localSubdomain: localSubdomain,
|
||||
}
|
||||
}
|
||||
|
||||
func FromCLI(cCtx *cli.Context) *CliEnv {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &CliEnv{
|
||||
stdout: cCtx.App.Writer,
|
||||
stderr: cCtx.App.ErrWriter,
|
||||
Path: NewPathStructure(
|
||||
cwd,
|
||||
cCtx.String(flagRootFolder),
|
||||
cCtx.String(flagDotNhostFolder),
|
||||
cCtx.String(flagNhostFolder),
|
||||
),
|
||||
authURL: cCtx.String(flagAuthURL),
|
||||
graphqlURL: cCtx.String(flagGraphqlURL),
|
||||
branch: cCtx.String(flagBranch),
|
||||
projectName: sanitizeName(cCtx.String(flagProjectName)),
|
||||
nhclient: nil,
|
||||
nhpublicclient: nil,
|
||||
localSubdomain: cCtx.String(flagLocalSubdomain),
|
||||
}
|
||||
}
|
||||
|
||||
func (ce *CliEnv) ProjectName() string {
|
||||
return ce.projectName
|
||||
}
|
||||
|
||||
func (ce *CliEnv) LocalSubdomain() string {
|
||||
return ce.localSubdomain
|
||||
}
|
||||
|
||||
func (ce *CliEnv) AuthURL() string {
|
||||
return ce.authURL
|
||||
}
|
||||
|
||||
func (ce *CliEnv) GraphqlURL() string {
|
||||
return ce.graphqlURL
|
||||
}
|
||||
|
||||
func (ce *CliEnv) Branch() string {
|
||||
return ce.branch
|
||||
}
|
||||
|
||||
func (ce *CliEnv) GetNhostClient(ctx context.Context) (*nhostclient.Client, error) {
|
||||
if ce.nhclient == nil {
|
||||
session, err := ce.LoadSession(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load session: %w", err)
|
||||
}
|
||||
|
||||
ce.nhclient = nhostclient.New(
|
||||
ce.authURL,
|
||||
ce.graphqlURL,
|
||||
graphql.WithAccessToken(session.Session.AccessToken),
|
||||
)
|
||||
}
|
||||
|
||||
return ce.nhclient, nil
|
||||
}
|
||||
|
||||
func (ce *CliEnv) GetNhostPublicClient() (*nhostclient.Client, error) {
|
||||
if ce.nhpublicclient == nil {
|
||||
ce.nhpublicclient = nhostclient.New(ce.authURL, ce.graphqlURL)
|
||||
}
|
||||
|
||||
return ce.nhpublicclient, nil
|
||||
}
|
||||
101
cli/clienv/filesystem.go
Normal file
101
cli/clienv/filesystem.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package clienv
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type PathStructure struct {
|
||||
workingDir string
|
||||
root string
|
||||
dotNhostFolder string
|
||||
nhostFolder string
|
||||
}
|
||||
|
||||
func NewPathStructure(
|
||||
workingDir, root, dotNhostFolder, nhostFolder string,
|
||||
) *PathStructure {
|
||||
return &PathStructure{
|
||||
workingDir: workingDir,
|
||||
root: root,
|
||||
dotNhostFolder: dotNhostFolder,
|
||||
nhostFolder: nhostFolder,
|
||||
}
|
||||
}
|
||||
|
||||
func (p PathStructure) WorkingDir() string {
|
||||
return p.workingDir
|
||||
}
|
||||
|
||||
func (p PathStructure) Root() string {
|
||||
return p.root
|
||||
}
|
||||
|
||||
func (p PathStructure) DotNhostFolder() string {
|
||||
return p.dotNhostFolder
|
||||
}
|
||||
|
||||
func (p PathStructure) NhostFolder() string {
|
||||
return p.nhostFolder
|
||||
}
|
||||
|
||||
func (p PathStructure) AuthFile() string {
|
||||
return filepath.Join(PathStateHome(), "auth.json")
|
||||
}
|
||||
|
||||
func (p PathStructure) NhostToml() string {
|
||||
return filepath.Join(p.nhostFolder, "nhost.toml")
|
||||
}
|
||||
|
||||
func (p PathStructure) OverlaysFolder() string {
|
||||
return filepath.Join(p.nhostFolder, "overlays")
|
||||
}
|
||||
|
||||
func (p PathStructure) Overlay(subdomain string) string {
|
||||
return filepath.Join(p.OverlaysFolder(), subdomain+".json")
|
||||
}
|
||||
|
||||
func (p PathStructure) Secrets() string {
|
||||
return filepath.Join(p.root, ".secrets")
|
||||
}
|
||||
|
||||
func (p PathStructure) HasuraConfig() string {
|
||||
return filepath.Join(p.nhostFolder, "config.yaml")
|
||||
}
|
||||
|
||||
func (p PathStructure) ProjectFile() string {
|
||||
return filepath.Join(p.dotNhostFolder, "project.json")
|
||||
}
|
||||
|
||||
func (p PathStructure) DockerCompose() string {
|
||||
return filepath.Join(p.dotNhostFolder, "docker-compose.yaml")
|
||||
}
|
||||
|
||||
func (p PathStructure) Functions() string {
|
||||
return filepath.Join(p.root, "functions")
|
||||
}
|
||||
|
||||
func PathExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func PathStateHome() string {
|
||||
var path string
|
||||
if os.Getenv("XDG_STATE_HOME") != "" {
|
||||
path = filepath.Join(os.Getenv("XDG_STATE_HOME"), "nhost")
|
||||
} else {
|
||||
path = filepath.Join(os.Getenv("HOME"), ".nhost", "state")
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func (p PathStructure) RunServiceOverlaysFolder(configPath string) string {
|
||||
base := filepath.Dir(configPath)
|
||||
return filepath.Join(base, "nhost", "overlays")
|
||||
}
|
||||
|
||||
func (p PathStructure) RunServiceOverlay(configPath, subdomain string) string {
|
||||
return filepath.Join(p.RunServiceOverlaysFolder(configPath), "run-"+subdomain+".json")
|
||||
}
|
||||
108
cli/clienv/flags.go
Normal file
108
cli/clienv/flags.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package clienv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagAuthURL = "auth-url"
|
||||
flagGraphqlURL = "graphql-url"
|
||||
flagBranch = "branch"
|
||||
flagProjectName = "project-name"
|
||||
flagRootFolder = "root-folder"
|
||||
flagNhostFolder = "nhost-folder"
|
||||
flagDotNhostFolder = "dot-nhost-folder"
|
||||
flagLocalSubdomain = "local-subdomain"
|
||||
)
|
||||
|
||||
func getGitBranchName() string {
|
||||
repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{
|
||||
DetectDotGit: true,
|
||||
EnableDotGitCommonDir: false,
|
||||
})
|
||||
if err != nil {
|
||||
return "nogit"
|
||||
}
|
||||
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return "nogit"
|
||||
}
|
||||
|
||||
return head.Name().Short()
|
||||
}
|
||||
|
||||
func Flags() ([]cli.Flag, error) {
|
||||
fullWorkingDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
branch := getGitBranchName()
|
||||
|
||||
workingDir := "."
|
||||
dotNhostFolder := filepath.Join(workingDir, ".nhost")
|
||||
nhostFolder := filepath.Join(workingDir, "nhost")
|
||||
|
||||
return []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagAuthURL,
|
||||
Usage: "Nhost auth URL",
|
||||
EnvVars: []string{"NHOST_CLI_AUTH_URL"},
|
||||
Value: "https://otsispdzcwxyqzbfntmj.auth.eu-central-1.nhost.run/v1",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagGraphqlURL,
|
||||
Usage: "Nhost GraphQL URL",
|
||||
EnvVars: []string{"NHOST_CLI_GRAPHQL_URL"},
|
||||
Value: "https://otsispdzcwxyqzbfntmj.graphql.eu-central-1.nhost.run/v1",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagBranch,
|
||||
Usage: "Git branch name. If not set, it will be detected from the current git repository. This flag is used to dynamically create docker volumes for each branch. If you want to have a static volume name or if you are not using git, set this flag to a static value.", //nolint:lll
|
||||
EnvVars: []string{"BRANCH"},
|
||||
Value: branch,
|
||||
Hidden: false,
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagRootFolder,
|
||||
Usage: "Root folder of project\n\t",
|
||||
EnvVars: []string{"NHOST_ROOT_FOLDER"},
|
||||
Value: workingDir,
|
||||
Category: "Project structure",
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagDotNhostFolder,
|
||||
Usage: "Path to .nhost folder\n\t",
|
||||
EnvVars: []string{"NHOST_DOT_NHOST_FOLDER"},
|
||||
Value: dotNhostFolder,
|
||||
Category: "Project structure",
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagNhostFolder,
|
||||
Usage: "Path to nhost folder\n\t",
|
||||
EnvVars: []string{"NHOST_NHOST_FOLDER"},
|
||||
Value: nhostFolder,
|
||||
Category: "Project structure",
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagProjectName,
|
||||
Usage: "Project name",
|
||||
Value: filepath.Base(fullWorkingDir),
|
||||
EnvVars: []string{"NHOST_PROJECT_NAME"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagLocalSubdomain,
|
||||
Usage: "Local subdomain to reach the development environment",
|
||||
Value: "local",
|
||||
EnvVars: []string{"NHOST_LOCAL_SUBDOMAIN"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
91
cli/clienv/style.go
Normal file
91
cli/clienv/style.go
Normal file
@@ -0,0 +1,91 @@
|
||||
//nolint:gochecknoglobals
|
||||
package clienv
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
ANSIColorWhite = lipgloss.Color("15")
|
||||
ANSIColorCyan = lipgloss.Color("14")
|
||||
ANSIColorPurple = lipgloss.Color("13")
|
||||
ANSIColorBlue = lipgloss.Color("12")
|
||||
ANSIColorYellow = lipgloss.Color("11")
|
||||
ANSIColorGreen = lipgloss.Color("10")
|
||||
ANSIColorRed = lipgloss.Color("9")
|
||||
ANSIColorGray = lipgloss.Color("8")
|
||||
)
|
||||
|
||||
const (
|
||||
IconInfo = "ℹ️"
|
||||
IconWarn = "⚠"
|
||||
)
|
||||
|
||||
var info = lipgloss.NewStyle().
|
||||
Foreground(ANSIColorCyan).
|
||||
Render
|
||||
|
||||
var warn = lipgloss.NewStyle().
|
||||
Foreground(ANSIColorYellow).
|
||||
Render
|
||||
|
||||
var promptMessage = lipgloss.NewStyle().
|
||||
Foreground(ANSIColorCyan).
|
||||
Bold(true).
|
||||
Render
|
||||
|
||||
func (ce *CliEnv) Println(msg string, a ...any) {
|
||||
if _, err := fmt.Fprintln(ce.stdout, fmt.Sprintf(msg, a...)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ce *CliEnv) Infoln(msg string, a ...any) {
|
||||
if _, err := fmt.Fprintln(ce.stdout, info(fmt.Sprintf(msg, a...))); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ce *CliEnv) Warnln(msg string, a ...any) {
|
||||
if _, err := fmt.Fprintln(ce.stdout, warn(fmt.Sprintf(msg, a...))); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ce *CliEnv) PromptMessage(msg string, a ...any) {
|
||||
if _, err := fmt.Fprint(ce.stdout, promptMessage("- "+fmt.Sprintf(msg, a...))); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ce *CliEnv) PromptInput(hide bool) (string, error) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
var (
|
||||
response string
|
||||
err error
|
||||
)
|
||||
|
||||
if !hide {
|
||||
response, err = reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
} else {
|
||||
output, err := term.ReadPassword(syscall.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
response = string(output)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(response), err
|
||||
}
|
||||
44
cli/clienv/table.go
Normal file
44
cli/clienv/table.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package clienv
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
type Column struct {
|
||||
Header string
|
||||
Rows []string
|
||||
}
|
||||
|
||||
func Table(columns ...Column) string {
|
||||
list := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, true, false, false).
|
||||
BorderForeground(ANSIColorGray).
|
||||
Padding(1)
|
||||
// Width(30 + 1) //nolint:mnd
|
||||
|
||||
listHeader := lipgloss.NewStyle().
|
||||
Foreground(ANSIColorPurple).
|
||||
Render
|
||||
|
||||
listItem := lipgloss.NewStyle().Render
|
||||
|
||||
strs := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
c := make([]string, len(col.Rows)+1)
|
||||
|
||||
c[0] = listHeader(col.Header)
|
||||
for i, row := range col.Rows {
|
||||
c[i+1] = listItem(row)
|
||||
}
|
||||
|
||||
strs[i] = list.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
c...,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
strs...,
|
||||
)
|
||||
}
|
||||
73
cli/clienv/wf_app_info.go
Normal file
73
cli/clienv/wf_app_info.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package clienv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/nhost/nhost/cli/nhostclient/graphql"
|
||||
)
|
||||
|
||||
func getRemoteAppInfo(
|
||||
ctx context.Context,
|
||||
ce *CliEnv,
|
||||
subdomain string,
|
||||
) (*graphql.AppSummaryFragment, error) {
|
||||
cl, err := ce.GetNhostClient(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
resp, err := cl.GetOrganizationsAndWorkspacesApps(
|
||||
ctx,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get workspaces: %w", err)
|
||||
}
|
||||
|
||||
for _, workspace := range resp.Workspaces {
|
||||
for _, app := range workspace.Apps {
|
||||
if app.Subdomain == subdomain {
|
||||
return app, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, organization := range resp.Organizations {
|
||||
for _, app := range organization.Apps {
|
||||
if app.Subdomain == subdomain {
|
||||
return app, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to find app with subdomain: %s", subdomain) //nolint:err113
|
||||
}
|
||||
|
||||
func (ce *CliEnv) GetAppInfo(
|
||||
ctx context.Context,
|
||||
subdomain string,
|
||||
) (*graphql.AppSummaryFragment, error) {
|
||||
if subdomain != "" {
|
||||
return getRemoteAppInfo(ctx, ce, subdomain)
|
||||
}
|
||||
|
||||
var project *graphql.AppSummaryFragment
|
||||
if err := UnmarshalFile(ce.Path.ProjectFile(), &project, json.Unmarshal); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
project, err = ce.Link(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
ce.Warnln("Failed to find linked project: %v", err)
|
||||
ce.Infoln("Please run `nhost link` to link a project first")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
173
cli/clienv/wf_link.go
Normal file
173
cli/clienv/wf_link.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package clienv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/nhost/nhost/cli/nhostclient/graphql"
|
||||
)
|
||||
|
||||
func Printlist(ce *CliEnv, orgs *graphql.GetOrganizationsAndWorkspacesApps) error {
|
||||
if len(orgs.GetWorkspaces())+len(orgs.GetOrganizations()) == 0 {
|
||||
return errors.New("no apps found") //nolint:err113
|
||||
}
|
||||
|
||||
num := Column{
|
||||
Header: "#",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
subdomain := Column{
|
||||
Header: "Subdomain",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
project := Column{
|
||||
Header: "Project",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
organization := Column{
|
||||
Header: "Organization/Workspace",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
region := Column{
|
||||
Header: "Region",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
|
||||
for _, org := range orgs.GetOrganizations() {
|
||||
for _, app := range org.Apps {
|
||||
num.Rows = append(num.Rows, strconv.Itoa(len(num.Rows)+1))
|
||||
subdomain.Rows = append(subdomain.Rows, app.Subdomain)
|
||||
project.Rows = append(project.Rows, app.Name)
|
||||
organization.Rows = append(organization.Rows, org.Name)
|
||||
region.Rows = append(region.Rows, app.Region.Name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ws := range orgs.GetWorkspaces() {
|
||||
for _, app := range ws.Apps {
|
||||
num.Rows = append(num.Rows, strconv.Itoa(len(num.Rows)+1))
|
||||
subdomain.Rows = append(subdomain.Rows, app.Subdomain)
|
||||
project.Rows = append(project.Rows, app.Name)
|
||||
organization.Rows = append(organization.Rows, ws.Name+"*")
|
||||
region.Rows = append(region.Rows, app.Region.Name)
|
||||
}
|
||||
}
|
||||
|
||||
ce.Println("%s", Table(num, subdomain, project, organization, region))
|
||||
ce.Println("* Legacy Workspace")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirmApp(ce *CliEnv, app *graphql.AppSummaryFragment) error {
|
||||
ce.PromptMessage("Enter project subdomain to confirm: ")
|
||||
|
||||
confirm, err := ce.PromptInput(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
if confirm != app.Subdomain {
|
||||
return errors.New("input doesn't match the subdomain") //nolint:err113
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getApp(
|
||||
orgs *graphql.GetOrganizationsAndWorkspacesApps,
|
||||
idx string,
|
||||
) (*graphql.AppSummaryFragment, error) {
|
||||
x := 1
|
||||
|
||||
var app *graphql.AppSummaryFragment
|
||||
|
||||
OUTER:
|
||||
|
||||
for _, orgs := range orgs.GetOrganizations() {
|
||||
for _, a := range orgs.GetApps() {
|
||||
if strconv.Itoa(x) == idx {
|
||||
a := a
|
||||
app = a
|
||||
|
||||
break OUTER
|
||||
}
|
||||
|
||||
x++
|
||||
}
|
||||
}
|
||||
|
||||
if app != nil {
|
||||
return app, nil
|
||||
}
|
||||
|
||||
OUTER2:
|
||||
for _, ws := range orgs.GetWorkspaces() {
|
||||
for _, a := range ws.GetApps() {
|
||||
if strconv.Itoa(x) == idx {
|
||||
a := a
|
||||
app = a
|
||||
|
||||
break OUTER2
|
||||
}
|
||||
|
||||
x++
|
||||
}
|
||||
}
|
||||
|
||||
if app == nil {
|
||||
return nil, errors.New("invalid input") //nolint:err113
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (ce *CliEnv) Link(ctx context.Context) (*graphql.AppSummaryFragment, error) {
|
||||
cl, err := ce.GetNhostClient(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
orgs, err := cl.GetOrganizationsAndWorkspacesApps(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get workspaces: %w", err)
|
||||
}
|
||||
|
||||
if len(orgs.GetWorkspaces())+len(orgs.GetOrganizations()) == 0 {
|
||||
return nil, errors.New("no apps found") //nolint:err113
|
||||
}
|
||||
|
||||
if err := Printlist(ce, orgs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ce.PromptMessage("Select the workspace # to link: ")
|
||||
|
||||
idx, err := ce.PromptInput(false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read workspace: %w", err)
|
||||
}
|
||||
|
||||
app, err := getApp(orgs, idx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := confirmApp(ce, app); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(ce.Path.DotNhostFolder(), 0o755); err != nil { //nolint:mnd
|
||||
return nil, fmt.Errorf("failed to create .nhost folder: %w", err)
|
||||
}
|
||||
|
||||
if err := MarshalFile(app, ce.Path.ProjectFile(), json.Marshal); err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal project information: %w", err)
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
296
cli/clienv/wf_login.go
Normal file
296
cli/clienv/wf_login.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package clienv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/nhost/nhost/cli/nhostclient"
|
||||
"github.com/nhost/nhost/cli/nhostclient/credentials"
|
||||
"github.com/nhost/nhost/cli/ssl"
|
||||
)
|
||||
|
||||
func savePAT(
|
||||
ce *CliEnv,
|
||||
session credentials.Credentials,
|
||||
) error {
|
||||
dir := filepath.Dir(ce.Path.AuthFile())
|
||||
if !PathExists(dir) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to create dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := MarshalFile(session, ce.Path.AuthFile(), json.Marshal); err != nil {
|
||||
return fmt.Errorf("failed to write PAT to file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func signinHandler(ch chan<- string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ch <- r.URL.Query().Get("refreshToken")
|
||||
|
||||
fmt.Fprintf(w, "You may now close this window.")
|
||||
}
|
||||
}
|
||||
|
||||
func openBrowser(ctx context.Context, url string) error {
|
||||
var (
|
||||
cmd string
|
||||
args []string
|
||||
)
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
default: // "linux", "freebsd", "openbsd", "netbsd"
|
||||
cmd = "xdg-open"
|
||||
}
|
||||
|
||||
args = append(args, url)
|
||||
if err := exec.CommandContext(ctx, cmd, args...).Start(); err != nil {
|
||||
return fmt.Errorf("failed to open browser: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getTLSServer() (*http.Server, error) {
|
||||
block, _ := pem.Decode(ssl.LocalKeyFile)
|
||||
// Parse the PEM data to obtain the private key
|
||||
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Type assert the private key to crypto.PrivateKey
|
||||
pk, ok := privateKey.(crypto.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New( //nolint:err113
|
||||
"failed to type assert private key to crypto.PrivateKey",
|
||||
)
|
||||
}
|
||||
|
||||
block, _ = pem.Decode(ssl.LocalCertFile)
|
||||
|
||||
certificate, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{ //nolint:exhaustruct
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CipherSuites: nil,
|
||||
Certificates: []tls.Certificate{
|
||||
{ //nolint:exhaustruct
|
||||
Certificate: [][]byte{certificate.Raw},
|
||||
PrivateKey: pk,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &http.Server{ //nolint:exhaustruct
|
||||
Addr: ":8099",
|
||||
TLSConfig: tlsConfig,
|
||||
ReadHeaderTimeout: time.Second * 10, //nolint:mnd
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ce *CliEnv) loginPAT(pat string) credentials.Credentials {
|
||||
session := credentials.Credentials{
|
||||
ID: "",
|
||||
PersonalAccessToken: pat,
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func (ce *CliEnv) loginEmailPassword(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
password string,
|
||||
) (credentials.Credentials, error) {
|
||||
cl := nhostclient.New(ce.AuthURL(), ce.GraphqlURL())
|
||||
|
||||
var err error
|
||||
|
||||
if email == "" {
|
||||
ce.PromptMessage("email: ")
|
||||
|
||||
email, err = ce.PromptInput(false)
|
||||
if err != nil {
|
||||
return credentials.Credentials{}, fmt.Errorf("failed to read email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
ce.PromptMessage("password: ")
|
||||
password, err = ce.PromptInput(true)
|
||||
ce.Println("")
|
||||
|
||||
if err != nil {
|
||||
return credentials.Credentials{}, fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ce.Infoln("Authenticating")
|
||||
|
||||
loginResp, err := cl.Login(ctx, email, password)
|
||||
if err != nil {
|
||||
return credentials.Credentials{}, fmt.Errorf("failed to login: %w", err)
|
||||
}
|
||||
|
||||
session, err := cl.CreatePAT(ctx, loginResp.Session.AccessToken)
|
||||
if err != nil {
|
||||
return credentials.Credentials{}, fmt.Errorf("failed to create PAT: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Successfully logged in")
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (ce *CliEnv) loginGithub(ctx context.Context) (credentials.Credentials, error) {
|
||||
refreshToken := make(chan string)
|
||||
http.HandleFunc("/signin", signinHandler(refreshToken))
|
||||
|
||||
go func() {
|
||||
server, err := getTLSServer()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := server.ListenAndServeTLS("", ""); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
signinPage := ce.AuthURL() + "/signin/provider/github/?redirectTo=https://local.dashboard.local.nhost.run:8099/signin"
|
||||
ce.Infoln("Opening browser to sign-in")
|
||||
|
||||
if err := openBrowser(ctx, signinPage); err != nil {
|
||||
return credentials.Credentials{}, err
|
||||
}
|
||||
|
||||
ce.Infoln("Waiting for sign-in to complete")
|
||||
|
||||
refreshTokenValue := <-refreshToken
|
||||
|
||||
cl := nhostclient.New(ce.AuthURL(), ce.GraphqlURL())
|
||||
|
||||
refreshTokenResp, err := cl.RefreshToken(ctx, refreshTokenValue)
|
||||
if err != nil {
|
||||
return credentials.Credentials{}, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
session, err := cl.CreatePAT(ctx, refreshTokenResp.AccessToken)
|
||||
if err != nil {
|
||||
return credentials.Credentials{}, fmt.Errorf("failed to create PAT: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Successfully logged in")
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (ce *CliEnv) loginMethod(ctx context.Context) (credentials.Credentials, error) {
|
||||
ce.Infoln("Select authentication method:\n1. PAT\n2. Email/Password\n3. Github")
|
||||
ce.PromptMessage("method: ")
|
||||
|
||||
method, err := ce.PromptInput(false)
|
||||
if err != nil {
|
||||
return credentials.Credentials{}, fmt.Errorf(
|
||||
"failed to read authentication method: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var session credentials.Credentials
|
||||
|
||||
switch method {
|
||||
case "1":
|
||||
ce.PromptMessage("PAT: ")
|
||||
|
||||
pat, err := ce.PromptInput(true)
|
||||
if err != nil {
|
||||
return credentials.Credentials{}, fmt.Errorf("failed to read PAT: %w", err)
|
||||
}
|
||||
|
||||
session = ce.loginPAT(pat)
|
||||
case "2":
|
||||
session, err = ce.loginEmailPassword(ctx, "", "")
|
||||
case "3":
|
||||
session, err = ce.loginGithub(ctx)
|
||||
default:
|
||||
return ce.loginMethod(ctx)
|
||||
}
|
||||
|
||||
return session, err
|
||||
}
|
||||
|
||||
func (ce *CliEnv) verifyEmail(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) error {
|
||||
ce.Infoln("Your email address is not verified")
|
||||
|
||||
cl := nhostclient.New(ce.AuthURL(), ce.GraphqlURL())
|
||||
if err := cl.VerifyEmail(ctx, email); err != nil {
|
||||
return fmt.Errorf("failed to send verification email: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("A verification email has been sent to %s", email)
|
||||
ce.Infoln("Please verify your email address and try again")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ce *CliEnv) Login(
|
||||
ctx context.Context,
|
||||
pat string,
|
||||
email string,
|
||||
password string,
|
||||
) (credentials.Credentials, error) {
|
||||
var (
|
||||
session credentials.Credentials
|
||||
err error
|
||||
)
|
||||
|
||||
switch {
|
||||
case pat != "":
|
||||
session = ce.loginPAT(pat)
|
||||
case email != "" || password != "":
|
||||
session, err = ce.loginEmailPassword(ctx, email, password)
|
||||
default:
|
||||
session, err = ce.loginMethod(ctx)
|
||||
}
|
||||
|
||||
var reqErr *nhostclient.RequestError
|
||||
if errors.As(err, &reqErr) && reqErr.ErrorCode == "unverified-user" {
|
||||
return credentials.Credentials{}, ce.verifyEmail(ctx, email)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return session, err
|
||||
}
|
||||
|
||||
if err := savePAT(ce, session); err != nil {
|
||||
return credentials.Credentials{}, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
52
cli/clienv/wf_marshal.go
Normal file
52
cli/clienv/wf_marshal.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package clienv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
var ErrNoContent = errors.New("no content")
|
||||
|
||||
func UnmarshalFile(filepath string, v any, f func([]byte, any) error) error {
|
||||
r, err := os.OpenFile(filepath, os.O_RDONLY, 0o600) //nolint:mnd
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read contents of reader: %w", err)
|
||||
}
|
||||
|
||||
if len(b) == 0 {
|
||||
return ErrNoContent
|
||||
}
|
||||
|
||||
if err := f(b, v); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal object: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func MarshalFile(v any, filepath string, fn func(any) ([]byte, error)) error {
|
||||
f, err := os.OpenFile(filepath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) //nolint:mnd
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
b, err := fn(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling object: %w", err)
|
||||
}
|
||||
|
||||
if _, err := f.Write(b); err != nil {
|
||||
return fmt.Errorf("error writing marshalled object: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
31
cli/clienv/wf_session.go
Normal file
31
cli/clienv/wf_session.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package clienv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/nhost/cli/nhostclient"
|
||||
"github.com/nhost/nhost/cli/nhostclient/credentials"
|
||||
)
|
||||
|
||||
func (ce *CliEnv) LoadSession(
|
||||
ctx context.Context,
|
||||
) (credentials.Session, error) {
|
||||
var creds credentials.Credentials
|
||||
if err := UnmarshalFile(ce.Path.AuthFile(), &creds, json.Unmarshal); err != nil {
|
||||
creds, err = ce.Login(ctx, "", "", "")
|
||||
if err != nil {
|
||||
return credentials.Session{}, fmt.Errorf("failed to login: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cl := nhostclient.New(ce.AuthURL(), ce.GraphqlURL())
|
||||
|
||||
session, err := cl.LoginPAT(ctx, creds.PersonalAccessToken)
|
||||
if err != nil {
|
||||
return credentials.Session{}, fmt.Errorf("failed to login: %w", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
102
cli/cmd/config/apply.go
Normal file
102
cli/cmd/config/apply.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandApply() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "apply",
|
||||
Aliases: []string{},
|
||||
Usage: "Apply configuration to cloud project",
|
||||
Action: commandApply,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagSubdomain,
|
||||
Usage: "Subdomain of the Nhost project to apply configuration to. Defaults to linked project",
|
||||
Required: true,
|
||||
EnvVars: []string{"NHOST_SUBDOMAIN"},
|
||||
},
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagYes,
|
||||
Usage: "Skip confirmation",
|
||||
EnvVars: []string{"NHOST_YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandApply(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Validating configuration...")
|
||||
|
||||
cfg, _, err := ValidateRemote(
|
||||
cCtx.Context,
|
||||
ce,
|
||||
proj.GetSubdomain(),
|
||||
proj.GetID(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return Apply(cCtx.Context, ce, proj.ID, cfg, cCtx.Bool(flagYes))
|
||||
}
|
||||
|
||||
func Apply(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
appID string,
|
||||
cfg *model.ConfigConfig,
|
||||
skipConfirmation bool,
|
||||
) error {
|
||||
if !skipConfirmation {
|
||||
ce.PromptMessage(
|
||||
"We are going to overwrite the project's configuration. Do you want to proceed? [y/N] ",
|
||||
)
|
||||
|
||||
resp, err := ce.PromptInput(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
if resp != "y" && resp != "Y" {
|
||||
return errors.New("aborting") //nolint:err113
|
||||
}
|
||||
}
|
||||
|
||||
cl, err := ce.GetNhostClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
if _, err := cl.ReplaceConfigRawJSON(
|
||||
ctx,
|
||||
appID,
|
||||
string(b),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to apply config: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Configuration applied successfully!")
|
||||
|
||||
return nil
|
||||
}
|
||||
22
cli/cmd/config/config.go
Normal file
22
cli/cmd/config/config.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package config
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
const flagSubdomain = "subdomain"
|
||||
|
||||
func Command() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "config",
|
||||
Aliases: []string{},
|
||||
Usage: "Perform config operations",
|
||||
Subcommands: []*cli.Command{
|
||||
CommandDefault(),
|
||||
CommandExample(),
|
||||
CommandApply(),
|
||||
CommandPull(),
|
||||
CommandShow(),
|
||||
CommandValidate(),
|
||||
CommandEdit(),
|
||||
},
|
||||
}
|
||||
}
|
||||
58
cli/cmd/config/default.go
Normal file
58
cli/cmd/config/default.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/project"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandDefault() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "default",
|
||||
Aliases: []string{},
|
||||
Usage: "Create default configuration and secrets",
|
||||
Action: commandDefault,
|
||||
Flags: []cli.Flag{},
|
||||
}
|
||||
}
|
||||
|
||||
func commandDefault(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
if err := os.MkdirAll(ce.Path.NhostFolder(), 0o755); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to create nhost folder: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Initializing Nhost project")
|
||||
|
||||
if err := InitConfigAndSecrets(ce); err != nil {
|
||||
return fmt.Errorf("failed to initialize project: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Successfully generated default configuration and secrets")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitConfigAndSecrets(ce *clienv.CliEnv) error {
|
||||
config, err := project.DefaultConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create default config: %w", err)
|
||||
}
|
||||
|
||||
if err := clienv.MarshalFile(config, ce.Path.NhostToml(), toml.Marshal); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
secrets := project.DefaultSecrets()
|
||||
if err := clienv.MarshalFile(secrets, ce.Path.Secrets(), env.Marshal); err != nil {
|
||||
return fmt.Errorf("failed to save secrets: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
182
cli/cmd/config/edit.go
Normal file
182
cli/cmd/config/edit.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/wI2L/jsondiff"
|
||||
)
|
||||
|
||||
const (
|
||||
flagEditor = "editor"
|
||||
)
|
||||
|
||||
func CommandEdit() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "edit",
|
||||
Aliases: []string{},
|
||||
Usage: "Edit base configuration or an overlay",
|
||||
Action: edit,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagSubdomain,
|
||||
Usage: "If specified, edit this subdomain's overlay, otherwise edit base configuation",
|
||||
EnvVars: []string{"NHOST_SUBDOMAIN"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagEditor,
|
||||
Usage: "Editor to use",
|
||||
Value: "vim",
|
||||
EnvVars: []string{"EDITOR"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func EditFile(ctx context.Context, editor, filepath string) error {
|
||||
cmd := exec.CommandContext(
|
||||
ctx,
|
||||
editor,
|
||||
filepath,
|
||||
)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to open editor: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CopyConfig[T any](src, dst, overlayPath string) error {
|
||||
var cfg *T
|
||||
if err := clienv.UnmarshalFile(src, &cfg, toml.Unmarshal); err != nil {
|
||||
return fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
var err error
|
||||
if clienv.PathExists(overlayPath) {
|
||||
cfg, err = ApplyJSONPatches(*cfg, overlayPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply json patches: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := clienv.MarshalFile(cfg, dst, toml.Marshal); err != nil {
|
||||
return fmt.Errorf("failed to save temporary file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readFile(filepath string) (any, error) {
|
||||
f, err := os.Open(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
var v any
|
||||
if err := toml.Unmarshal(b, &v); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal toml: %w", err)
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func GenerateJSONPatch(origfilepath, newfilepath, dst string) error {
|
||||
origo, err := readFile(origfilepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert original toml to json: %w", err)
|
||||
}
|
||||
|
||||
newo, err := readFile(newfilepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert new toml to json: %w", err)
|
||||
}
|
||||
|
||||
patches, err := jsondiff.Compare(origo, newo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate json patch: %w", err)
|
||||
}
|
||||
|
||||
dstf, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) //nolint:mnd
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open destination file: %w", err)
|
||||
}
|
||||
defer dstf.Close()
|
||||
|
||||
sort.Slice(patches, func(i, j int) bool {
|
||||
return patches[i].Path < patches[j].Path
|
||||
})
|
||||
|
||||
dstb, err := json.MarshalIndent(patches, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prettify json: %w", err)
|
||||
}
|
||||
|
||||
if _, err := dstf.Write(dstb); err != nil {
|
||||
return fmt.Errorf("failed to write to destination file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func edit(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
if cCtx.String(flagSubdomain) == "" {
|
||||
if err := EditFile(cCtx.Context, cCtx.String(flagEditor), ce.Path.NhostToml()); err != nil {
|
||||
return fmt.Errorf("failed to edit config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(ce.Path.OverlaysFolder(), 0o755); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to create json patches directory: %w", err)
|
||||
}
|
||||
|
||||
tmpdir, err := os.MkdirTemp(os.TempDir(), "nhost-jsonpatch")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
tmpfileName := filepath.Join(tmpdir, "nhost.toml")
|
||||
|
||||
if err := CopyConfig[model.ConfigConfig](
|
||||
ce.Path.NhostToml(), tmpfileName, ce.Path.Overlay(cCtx.String(flagSubdomain)),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to copy config: %w", err)
|
||||
}
|
||||
|
||||
if err := EditFile(cCtx.Context, cCtx.String(flagEditor), tmpfileName); err != nil {
|
||||
return fmt.Errorf("failed to edit config: %w", err)
|
||||
}
|
||||
|
||||
if err := GenerateJSONPatch(
|
||||
ce.Path.NhostToml(), tmpfileName, ce.Path.Overlay(cCtx.String(flagSubdomain)),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to generate json patch: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
554
cli/cmd/config/example.go
Normal file
554
cli/cmd/config/example.go
Normal file
@@ -0,0 +1,554 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/be/services/mimir/schema"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandExample() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "example",
|
||||
Aliases: []string{},
|
||||
Usage: "Shows an example config file",
|
||||
Action: commandExample,
|
||||
Flags: []cli.Flag{},
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func commandExample(cCtx *cli.Context) error { //nolint:funlen,maintidx
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
//nolint:mnd
|
||||
cfg := model.ConfigConfig{
|
||||
Global: &model.ConfigGlobal{
|
||||
Environment: []*model.ConfigGlobalEnvironmentVariable{
|
||||
{
|
||||
Name: "NAME",
|
||||
Value: "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
Ai: &model.ConfigAI{
|
||||
Version: ptr("0.3.0"),
|
||||
Resources: &model.ConfigAIResources{
|
||||
Compute: &model.ConfigComputeResources{
|
||||
Cpu: 256,
|
||||
Memory: 512,
|
||||
},
|
||||
},
|
||||
Openai: &model.ConfigAIOpenai{
|
||||
Organization: ptr("org-id"),
|
||||
ApiKey: "opeanai-api-key",
|
||||
},
|
||||
AutoEmbeddings: &model.ConfigAIAutoEmbeddings{
|
||||
SynchPeriodMinutes: ptr(uint32(10)),
|
||||
},
|
||||
WebhookSecret: "this-is-a-webhook-secret",
|
||||
},
|
||||
Graphql: &model.ConfigGraphql{
|
||||
Security: &model.ConfigGraphqlSecurity{
|
||||
ForbidAminSecret: ptr(true),
|
||||
MaxDepthQueries: ptr(uint(4)),
|
||||
},
|
||||
},
|
||||
Hasura: &model.ConfigHasura{
|
||||
Version: new(string),
|
||||
JwtSecrets: []*model.ConfigJWTSecret{
|
||||
{
|
||||
Type: ptr("HS256"),
|
||||
Key: ptr("secret"),
|
||||
},
|
||||
},
|
||||
AdminSecret: "adminsecret",
|
||||
WebhookSecret: "webhooksecret",
|
||||
Settings: &model.ConfigHasuraSettings{
|
||||
CorsDomain: []string{"*"},
|
||||
DevMode: ptr(false),
|
||||
EnableAllowList: ptr(true),
|
||||
EnableConsole: ptr(true),
|
||||
EnableRemoteSchemaPermissions: ptr(true),
|
||||
EnabledAPIs: []string{
|
||||
"metadata",
|
||||
},
|
||||
InferFunctionPermissions: ptr(true),
|
||||
LiveQueriesMultiplexedRefetchInterval: ptr(uint32(1000)),
|
||||
StringifyNumericTypes: ptr(false),
|
||||
},
|
||||
AuthHook: &model.ConfigHasuraAuthHook{
|
||||
Url: "https://customauth.example.com/hook",
|
||||
Mode: ptr("POST"),
|
||||
SendRequestBody: ptr(true),
|
||||
},
|
||||
Logs: &model.ConfigHasuraLogs{
|
||||
Level: ptr("warn"),
|
||||
},
|
||||
Events: &model.ConfigHasuraEvents{
|
||||
HttpPoolSize: ptr(uint32(10)),
|
||||
},
|
||||
Resources: &model.ConfigResources{
|
||||
Compute: &model.ConfigResourcesCompute{
|
||||
Cpu: 500,
|
||||
Memory: 1024,
|
||||
},
|
||||
Replicas: ptr(uint8(1)),
|
||||
Networking: &model.ConfigNetworking{
|
||||
Ingresses: []*model.ConfigIngress{
|
||||
{
|
||||
Fqdn: []string{"hasura.example.com"},
|
||||
Tls: &model.ConfigIngressTls{
|
||||
ClientCA: ptr(
|
||||
"---BEGIN CERTIFICATE---\n...\n---END CERTIFICATE---",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Autoscaler: nil,
|
||||
},
|
||||
RateLimit: &model.ConfigRateLimit{
|
||||
Limit: 100,
|
||||
Interval: "15m",
|
||||
},
|
||||
},
|
||||
Functions: &model.ConfigFunctions{
|
||||
Node: &model.ConfigFunctionsNode{
|
||||
Version: ptr(int(22)),
|
||||
},
|
||||
Resources: &model.ConfigFunctionsResources{
|
||||
Networking: &model.ConfigNetworking{
|
||||
Ingresses: []*model.ConfigIngress{
|
||||
{
|
||||
Fqdn: []string{"hasura.example.com"},
|
||||
Tls: &model.ConfigIngressTls{
|
||||
ClientCA: ptr(
|
||||
"---BEGIN CERTIFICATE---\n...\n---END CERTIFICATE---",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RateLimit: &model.ConfigRateLimit{
|
||||
Limit: 100,
|
||||
Interval: "15m",
|
||||
},
|
||||
},
|
||||
Auth: &model.ConfigAuth{
|
||||
Version: ptr("0.25.0"),
|
||||
Misc: &model.ConfigAuthMisc{
|
||||
ConcealErrors: ptr(false),
|
||||
},
|
||||
ElevatedPrivileges: &model.ConfigAuthElevatedPrivileges{
|
||||
Mode: ptr("required"),
|
||||
},
|
||||
Resources: &model.ConfigResources{
|
||||
Compute: &model.ConfigResourcesCompute{
|
||||
Cpu: 250,
|
||||
Memory: 512,
|
||||
},
|
||||
Replicas: ptr(uint8(1)),
|
||||
Networking: &model.ConfigNetworking{
|
||||
Ingresses: []*model.ConfigIngress{
|
||||
{
|
||||
Fqdn: []string{"auth.example.com"},
|
||||
Tls: &model.ConfigIngressTls{
|
||||
ClientCA: ptr(
|
||||
"---BEGIN CERTIFICATE---\n...\n---END CERTIFICATE---",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Autoscaler: nil,
|
||||
},
|
||||
Redirections: &model.ConfigAuthRedirections{
|
||||
ClientUrl: ptr("https://example.com"),
|
||||
AllowedUrls: []string{
|
||||
"https://example.com",
|
||||
},
|
||||
},
|
||||
SignUp: &model.ConfigAuthSignUp{
|
||||
Enabled: ptr(true),
|
||||
DisableNewUsers: ptr(false),
|
||||
Turnstile: &model.ConfigAuthSignUpTurnstile{
|
||||
SecretKey: "turnstileSecretKey",
|
||||
},
|
||||
},
|
||||
User: &model.ConfigAuthUser{
|
||||
Roles: &model.ConfigAuthUserRoles{
|
||||
Default: ptr("user"),
|
||||
Allowed: []string{"user", "me"},
|
||||
},
|
||||
Locale: &model.ConfigAuthUserLocale{
|
||||
Default: ptr("en"),
|
||||
Allowed: []string{"en"},
|
||||
},
|
||||
Gravatar: &model.ConfigAuthUserGravatar{
|
||||
Enabled: ptr(true),
|
||||
Default: ptr("identicon"),
|
||||
Rating: ptr("g"),
|
||||
},
|
||||
Email: &model.ConfigAuthUserEmail{
|
||||
Allowed: []string{"asd@example.org"},
|
||||
Blocked: []string{"asd@example.com"},
|
||||
},
|
||||
EmailDomains: &model.ConfigAuthUserEmailDomains{
|
||||
Allowed: []string{"example.com"},
|
||||
Blocked: []string{"example.org"},
|
||||
},
|
||||
},
|
||||
Session: &model.ConfigAuthSession{
|
||||
AccessToken: &model.ConfigAuthSessionAccessToken{
|
||||
ExpiresIn: ptr(uint32(3600)),
|
||||
CustomClaims: []*model.ConfigAuthsessionaccessTokenCustomClaims{
|
||||
{
|
||||
Key: "key",
|
||||
Value: "value",
|
||||
Default: ptr("default-value"),
|
||||
},
|
||||
},
|
||||
},
|
||||
RefreshToken: &model.ConfigAuthSessionRefreshToken{
|
||||
ExpiresIn: ptr(uint32(3600)),
|
||||
},
|
||||
},
|
||||
Method: &model.ConfigAuthMethod{
|
||||
Anonymous: &model.ConfigAuthMethodAnonymous{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Otp: &model.ConfigAuthMethodOtp{
|
||||
Email: &model.ConfigAuthMethodOtpEmail{
|
||||
Enabled: ptr(true),
|
||||
},
|
||||
},
|
||||
EmailPasswordless: &model.ConfigAuthMethodEmailPasswordless{
|
||||
Enabled: ptr(true),
|
||||
},
|
||||
EmailPassword: &model.ConfigAuthMethodEmailPassword{
|
||||
HibpEnabled: ptr(true),
|
||||
EmailVerificationRequired: ptr(true),
|
||||
PasswordMinLength: ptr(uint8(12)),
|
||||
},
|
||||
SmsPasswordless: &model.ConfigAuthMethodSmsPasswordless{
|
||||
Enabled: ptr(true),
|
||||
},
|
||||
Oauth: &model.ConfigAuthMethodOauth{
|
||||
Apple: &model.ConfigAuthMethodOauthApple{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
KeyId: ptr("keyid"),
|
||||
TeamId: ptr("teamid"),
|
||||
Scope: []string{"scope"},
|
||||
PrivateKey: ptr("privatekey"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Azuread: &model.ConfigAuthMethodOauthAzuread{
|
||||
Tenant: ptr("tenant"),
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
},
|
||||
Bitbucket: &model.ConfigStandardOauthProvider{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
},
|
||||
Discord: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Scope: []string{"scope"},
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Entraid: &model.ConfigAuthMethodOauthEntraid{
|
||||
ClientId: ptr("entraidClientId"),
|
||||
ClientSecret: ptr("entraidClientSecret"),
|
||||
Enabled: ptr(true),
|
||||
Tenant: ptr("entraidTenant"),
|
||||
},
|
||||
Facebook: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Scope: []string{"scope"},
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Github: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Scope: []string{"scope"},
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Gitlab: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Scope: []string{"scope"},
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Google: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Scope: []string{"scope"},
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Linkedin: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Scope: []string{"scope"},
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Spotify: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Scope: []string{"scope"},
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Strava: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Scope: []string{"scope"},
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Twitch: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Scope: []string{"scope"},
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Twitter: &model.ConfigAuthMethodOauthTwitter{
|
||||
Enabled: ptr(true),
|
||||
ConsumerKey: ptr("consumerkey"),
|
||||
ConsumerSecret: ptr("consumersecret"),
|
||||
},
|
||||
Windowslive: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Scope: []string{"scope"},
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
Audience: ptr("audience"),
|
||||
},
|
||||
Workos: &model.ConfigAuthMethodOauthWorkos{
|
||||
Connection: ptr("connection"),
|
||||
Enabled: ptr(true),
|
||||
ClientId: ptr("clientid"),
|
||||
Organization: ptr("organization"),
|
||||
ClientSecret: ptr("clientsecret"),
|
||||
},
|
||||
},
|
||||
Webauthn: &model.ConfigAuthMethodWebauthn{
|
||||
Enabled: ptr(true),
|
||||
RelyingParty: &model.ConfigAuthMethodWebauthnRelyingParty{
|
||||
Id: ptr("example.com"),
|
||||
Name: ptr("name"),
|
||||
Origins: []string{
|
||||
"https://example.com",
|
||||
},
|
||||
},
|
||||
Attestation: &model.ConfigAuthMethodWebauthnAttestation{
|
||||
Timeout: ptr(uint32(60000)),
|
||||
},
|
||||
},
|
||||
},
|
||||
Totp: &model.ConfigAuthTotp{
|
||||
Enabled: ptr(true),
|
||||
Issuer: ptr("issuer"),
|
||||
},
|
||||
RateLimit: &model.ConfigAuthRateLimit{
|
||||
Emails: &model.ConfigRateLimit{
|
||||
Limit: 10,
|
||||
Interval: "5m",
|
||||
},
|
||||
Sms: &model.ConfigRateLimit{
|
||||
Limit: 10,
|
||||
Interval: "5m",
|
||||
},
|
||||
BruteForce: &model.ConfigRateLimit{
|
||||
Limit: 10,
|
||||
Interval: "5m",
|
||||
},
|
||||
Signups: &model.ConfigRateLimit{
|
||||
Limit: 10,
|
||||
Interval: "5m",
|
||||
},
|
||||
Global: &model.ConfigRateLimit{
|
||||
Limit: 100,
|
||||
Interval: "15m",
|
||||
},
|
||||
},
|
||||
},
|
||||
Postgres: &model.ConfigPostgres{
|
||||
Version: ptr("14-20230312-1"),
|
||||
Resources: &model.ConfigPostgresResources{
|
||||
Compute: &model.ConfigResourcesCompute{
|
||||
Cpu: 2000,
|
||||
Memory: 4096,
|
||||
},
|
||||
EnablePublicAccess: ptr(true),
|
||||
Storage: &model.ConfigPostgresResourcesStorage{
|
||||
Capacity: 20,
|
||||
},
|
||||
Replicas: nil,
|
||||
},
|
||||
Settings: &model.ConfigPostgresSettings{
|
||||
Jit: ptr("off"),
|
||||
MaxConnections: ptr(int32(100)),
|
||||
SharedBuffers: ptr("128MB"),
|
||||
EffectiveCacheSize: ptr("4GB"),
|
||||
MaintenanceWorkMem: ptr("64MB"),
|
||||
CheckpointCompletionTarget: ptr(float64(0.9)),
|
||||
WalBuffers: ptr("-1"),
|
||||
DefaultStatisticsTarget: ptr(int32(100)),
|
||||
RandomPageCost: ptr(float64(4)),
|
||||
EffectiveIOConcurrency: ptr(int32(1)),
|
||||
WorkMem: ptr("4MB"),
|
||||
HugePages: ptr("try"),
|
||||
MinWalSize: ptr("80MB"),
|
||||
MaxWalSize: ptr("1GB"),
|
||||
MaxWorkerProcesses: ptr(int32(8)),
|
||||
MaxParallelWorkersPerGather: ptr(int32(2)),
|
||||
MaxParallelWorkers: ptr(int32(8)),
|
||||
MaxParallelMaintenanceWorkers: ptr(int32(2)),
|
||||
WalLevel: ptr("replica"),
|
||||
MaxWalSenders: ptr(int32(10)),
|
||||
MaxReplicationSlots: ptr(int32(10)),
|
||||
ArchiveTimeout: ptr(int32(300)),
|
||||
TrackIoTiming: ptr("off"),
|
||||
},
|
||||
Pitr: &model.ConfigPostgresPitr{
|
||||
Retention: ptr(uint8(7)),
|
||||
},
|
||||
},
|
||||
Provider: &model.ConfigProvider{
|
||||
Smtp: &model.ConfigSmtp{
|
||||
User: "smtpUser",
|
||||
Password: "smtpPassword",
|
||||
Sender: "smtpSender",
|
||||
Host: "smtpHost",
|
||||
Port: 587, //nolint:mnd
|
||||
Secure: true,
|
||||
Method: "LOGIN",
|
||||
},
|
||||
Sms: &model.ConfigSms{
|
||||
Provider: ptr("twilio"),
|
||||
AccountSid: "twilioAccountSid",
|
||||
AuthToken: "twilioAuthToken",
|
||||
MessagingServiceId: "twilioMessagingServiceId",
|
||||
},
|
||||
},
|
||||
Storage: &model.ConfigStorage{
|
||||
Version: ptr("0.3.5"),
|
||||
Antivirus: &model.ConfigStorageAntivirus{
|
||||
Server: ptr("tcp://run-clamav:3310"),
|
||||
},
|
||||
Resources: &model.ConfigResources{
|
||||
Compute: &model.ConfigResourcesCompute{
|
||||
Cpu: 500,
|
||||
Memory: 1024,
|
||||
},
|
||||
Networking: nil,
|
||||
Replicas: ptr(uint8(1)),
|
||||
Autoscaler: nil,
|
||||
},
|
||||
RateLimit: &model.ConfigRateLimit{
|
||||
Limit: 100,
|
||||
Interval: "15m",
|
||||
},
|
||||
},
|
||||
Observability: &model.ConfigObservability{
|
||||
Grafana: &model.ConfigGrafana{
|
||||
AdminPassword: "grafanaAdminPassword",
|
||||
Smtp: &model.ConfigGrafanaSmtp{
|
||||
Host: "localhost",
|
||||
Port: 25,
|
||||
Sender: "admin@localhost",
|
||||
User: "smtpUser",
|
||||
Password: "smtpPassword",
|
||||
},
|
||||
Alerting: &model.ConfigGrafanaAlerting{
|
||||
Enabled: ptr(true),
|
||||
},
|
||||
Contacts: &model.ConfigGrafanaContacts{
|
||||
Emails: []string{
|
||||
"engineering@acme.com",
|
||||
},
|
||||
Pagerduty: []*model.ConfigGrafanacontactsPagerduty{
|
||||
{
|
||||
IntegrationKey: "integration-key",
|
||||
Severity: "critical",
|
||||
Class: "infra",
|
||||
Component: "backend",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
Discord: []*model.ConfigGrafanacontactsDiscord{
|
||||
{
|
||||
Url: "https://discord.com/api/webhooks/...",
|
||||
AvatarUrl: "https://discord.com/api/avatar/...",
|
||||
},
|
||||
},
|
||||
Slack: []*model.ConfigGrafanacontactsSlack{
|
||||
{
|
||||
Recipient: "recipient",
|
||||
Token: "token",
|
||||
Username: "username",
|
||||
IconEmoji: "danger",
|
||||
IconURL: "https://...",
|
||||
MentionUsers: []string{
|
||||
"user1", "user2",
|
||||
},
|
||||
MentionGroups: []string{
|
||||
"group1", "group2",
|
||||
},
|
||||
MentionChannel: "channel",
|
||||
Url: "https://slack.com/api/webhooks/...",
|
||||
EndpointURL: "https://slack.com/api/endpoint/...",
|
||||
},
|
||||
},
|
||||
Webhook: []*model.ConfigGrafanacontactsWebhook{
|
||||
{
|
||||
Url: "https://webhook.example.com",
|
||||
HttpMethod: "POST",
|
||||
Username: "user",
|
||||
Password: "password",
|
||||
AuthorizationScheme: "Bearer",
|
||||
AuthorizationCredentials: "token",
|
||||
MaxAlerts: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b, err := toml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
sch, err := schema.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
if err := sch.ValidateConfig(cfg); err != nil {
|
||||
return fmt.Errorf("failed to validate config: %w", err)
|
||||
}
|
||||
|
||||
ce.Println("%s", b)
|
||||
|
||||
return nil
|
||||
}
|
||||
205
cli/cmd/config/pull.go
Normal file
205
cli/cmd/config/pull.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/nhostclient/graphql"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
"github.com/nhost/nhost/cli/system"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultHasuraGraphqlAdminSecret = "nhost-admin-secret" //nolint:gosec
|
||||
DefaultGraphqlJWTSecret = "0f987876650b4a085e64594fae9219e7781b17506bec02489ad061fba8cb22db"
|
||||
DefaultNhostWebhookSecret = "nhost-webhook-secret" //nolint:gosec
|
||||
)
|
||||
|
||||
const (
|
||||
flagYes = "yes"
|
||||
)
|
||||
|
||||
func CommandPull() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "pull",
|
||||
Aliases: []string{},
|
||||
Usage: "Get cloud configuration",
|
||||
Action: commandPull,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagSubdomain,
|
||||
Usage: "Pull this subdomain's configuration. Defaults to linked project",
|
||||
EnvVars: []string{"NHOST_SUBDOMAIN"},
|
||||
},
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagYes,
|
||||
Usage: "Skip confirmation",
|
||||
EnvVars: []string{"NHOST_YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandPull(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
skipConfirmation := cCtx.Bool(flagYes)
|
||||
|
||||
if !skipConfirmation {
|
||||
if err := verifyFile(ce, ce.Path.NhostToml()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
writeSecrets := true
|
||||
|
||||
if !skipConfirmation {
|
||||
if err := verifyFile(ce, ce.Path.Secrets()); err != nil {
|
||||
writeSecrets = false
|
||||
}
|
||||
}
|
||||
|
||||
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
_, err = Pull(cCtx.Context, ce, proj, writeSecrets)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func verifyFile(ce *clienv.CliEnv, name string) error {
|
||||
if clienv.PathExists(name) {
|
||||
ce.PromptMessage("%s",
|
||||
name+" already exists. Do you want to overwrite it? [y/N] ",
|
||||
)
|
||||
|
||||
resp, err := ce.PromptInput(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
if resp != "y" && resp != "Y" {
|
||||
return errors.New("aborting") //nolint:err113
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func respToSecrets(env []*graphql.GetSecrets_AppSecrets, anonymize bool) model.Secrets {
|
||||
secrets := make(model.Secrets, len(env))
|
||||
for i, s := range env {
|
||||
if anonymize {
|
||||
switch s.Name {
|
||||
case "HASURA_GRAPHQL_ADMIN_SECRET":
|
||||
s.Value = DefaultHasuraGraphqlAdminSecret
|
||||
case "HASURA_GRAPHQL_JWT_SECRET":
|
||||
s.Value = DefaultGraphqlJWTSecret
|
||||
case "NHOST_WEBHOOK_SECRET":
|
||||
s.Value = DefaultNhostWebhookSecret
|
||||
default:
|
||||
s.Value = "FIXME"
|
||||
}
|
||||
}
|
||||
|
||||
secrets[i] = &model.ConfigEnvironmentVariable{
|
||||
Name: s.Name,
|
||||
Value: s.Value,
|
||||
}
|
||||
}
|
||||
|
||||
return secrets
|
||||
}
|
||||
|
||||
func pullSecrets(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
proj *graphql.AppSummaryFragment,
|
||||
) error {
|
||||
ce.Infoln("Getting secrets list from Nhost...")
|
||||
|
||||
cl, err := ce.GetNhostClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
resp, err := cl.GetSecrets(
|
||||
ctx,
|
||||
proj.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secrets: %w", err)
|
||||
}
|
||||
|
||||
secrets := respToSecrets(resp.GetAppSecrets(), true)
|
||||
if err := clienv.MarshalFile(&secrets, ce.Path.Secrets(), env.Marshal); err != nil {
|
||||
return fmt.Errorf("failed to save nhost.toml: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Adding .secrets to .gitignore...")
|
||||
|
||||
if err := system.AddToGitignore("\n.secrets\n"); err != nil {
|
||||
return fmt.Errorf("failed to add .secrets to .gitignore: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Pull(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
proj *graphql.AppSummaryFragment,
|
||||
writeSecrts bool,
|
||||
) (*model.ConfigConfig, error) {
|
||||
ce.Infoln("Pulling config from Nhost...")
|
||||
|
||||
cl, err := ce.GetNhostClient(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := cl.GetConfigRawJSON(
|
||||
ctx,
|
||||
proj.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config: %w", err)
|
||||
}
|
||||
|
||||
var v model.ConfigConfig
|
||||
if err := json.Unmarshal([]byte(cfg.ConfigRawJSON), &v); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(ce.Path.NhostFolder(), 0o755); err != nil { //nolint:mnd
|
||||
return nil, fmt.Errorf("failed to create nhost directory: %w", err)
|
||||
}
|
||||
|
||||
if err := clienv.MarshalFile(v, ce.Path.NhostToml(), toml.Marshal); err != nil {
|
||||
return nil, fmt.Errorf("failed to save nhost.toml: %w", err)
|
||||
}
|
||||
|
||||
if writeSecrts {
|
||||
if err := pullSecrets(ctx, ce, proj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
ce.Infoln("Success!")
|
||||
ce.Warnln(
|
||||
"- Review `nhost/nhost.toml` and make sure there are no secrets before you commit it to git.",
|
||||
)
|
||||
ce.Warnln("- Review `.secrets` file and set your development secrets")
|
||||
ce.Warnln("- Review `.secrets` was added to .gitignore")
|
||||
|
||||
return &v, nil
|
||||
}
|
||||
54
cli/cmd/config/show.go
Normal file
54
cli/cmd/config/show.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandShow() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "show",
|
||||
Aliases: []string{},
|
||||
Usage: "Shows configuration after resolving secrets",
|
||||
Description: "Note that this command will always use the local secrets, even if you specify subdomain",
|
||||
Action: commandShow,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagSubdomain,
|
||||
Usage: "Show this subdomain's rendered configuration. Defaults to base configuration",
|
||||
EnvVars: []string{"NHOST_SUBDOMAIN"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandShow(c *cli.Context) error {
|
||||
ce := clienv.FromCLI(c)
|
||||
|
||||
var secrets model.Secrets
|
||||
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to parse secrets, make sure secret values are between quotes: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cfg, err := Validate(ce, c.String(flagSubdomain), secrets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, err := toml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling config: %w", err)
|
||||
}
|
||||
|
||||
ce.Println("%s", b)
|
||||
|
||||
return nil
|
||||
}
|
||||
8
cli/cmd/config/testdata/validate/success/.secrets
vendored
Normal file
8
cli/cmd/config/testdata/validate/success/.secrets
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
HASURA_GRAPHQL_ADMIN_SECRET='nhost-admin-secret'
|
||||
HASURA_GRAPHQL_JWT_SECRET='0f987876650b4a085e64594fae9219e7781b17506bec02489ad061fba8cb22db'
|
||||
NHOST_WEBHOOK_SECRET='nhost-webhook-secret'
|
||||
GRAFANA_ADMIN_PASSWORD='grafana-admin-password'
|
||||
APPLE_CLIENT_ID='clientID'
|
||||
APPLE_KEY_ID='keyID'
|
||||
APPLE_TEAM_ID='teamID'
|
||||
APPLE_PRIVATE_KEY='privateKey'
|
||||
155
cli/cmd/config/testdata/validate/success/nhost/nhost.toml
vendored
Normal file
155
cli/cmd/config/testdata/validate/success/nhost/nhost.toml
vendored
Normal file
@@ -0,0 +1,155 @@
|
||||
[global]
|
||||
[[global.environment]]
|
||||
name = 'ENVIRONMENT'
|
||||
value = 'production'
|
||||
|
||||
[hasura]
|
||||
version = 'v2.24.1-ce'
|
||||
adminSecret = '{{ secrets.HASURA_GRAPHQL_ADMIN_SECRET }}'
|
||||
webhookSecret = '{{ secrets.NHOST_WEBHOOK_SECRET }}'
|
||||
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = '{{ secrets.HASURA_GRAPHQL_JWT_SECRET }}'
|
||||
|
||||
[hasura.settings]
|
||||
corsDomain = ['*']
|
||||
devMode = true
|
||||
enableAllowList = false
|
||||
enableConsole = true
|
||||
enableRemoteSchemaPermissions = false
|
||||
enabledAPIs = ['metadata', 'graphql', 'pgdump', 'config']
|
||||
|
||||
[hasura.logs]
|
||||
level = 'warn'
|
||||
|
||||
[hasura.events]
|
||||
httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 22
|
||||
|
||||
[auth]
|
||||
version = '0.20.0'
|
||||
|
||||
[auth.redirections]
|
||||
clientUrl = 'https://my.app.com'
|
||||
|
||||
[auth.signUp]
|
||||
enabled = true
|
||||
|
||||
[auth.user]
|
||||
[auth.user.roles]
|
||||
default = 'user'
|
||||
allowed = ['user', 'me']
|
||||
|
||||
[auth.user.locale]
|
||||
default = 'en'
|
||||
allowed = ['en']
|
||||
|
||||
[auth.user.gravatar]
|
||||
enabled = true
|
||||
default = 'blank'
|
||||
rating = 'g'
|
||||
|
||||
[auth.user.email]
|
||||
|
||||
[auth.user.emailDomains]
|
||||
|
||||
[auth.session]
|
||||
[auth.session.accessToken]
|
||||
expiresIn = 900
|
||||
|
||||
[auth.session.refreshToken]
|
||||
expiresIn = 2592000
|
||||
|
||||
[auth.method]
|
||||
[auth.method.anonymous]
|
||||
enabled = false
|
||||
|
||||
[auth.method.emailPasswordless]
|
||||
enabled = false
|
||||
|
||||
[auth.method.emailPassword]
|
||||
hibpEnabled = false
|
||||
emailVerificationRequired = true
|
||||
passwordMinLength = 9
|
||||
|
||||
[auth.method.smsPasswordless]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth]
|
||||
[auth.method.oauth.apple]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.APPLE_CLIENT_ID }}'
|
||||
keyId = '{{ secrets.APPLE_KEY_ID }}'
|
||||
teamId = '{{ secrets.APPLE_TEAM_ID }}'
|
||||
privateKey = '{{ secrets.APPLE_PRIVATE_KEY }}'
|
||||
|
||||
[auth.method.oauth.azuread]
|
||||
tenant = 'common'
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.bitbucket]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.discord]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.facebook]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.github]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.gitlab]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.google]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.linkedin]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.spotify]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.strava]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.twitch]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.twitter]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.windowslive]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.workos]
|
||||
enabled = false
|
||||
|
||||
[auth.method.webauthn]
|
||||
enabled = false
|
||||
|
||||
[auth.method.webauthn.attestation]
|
||||
timeout = 60000
|
||||
|
||||
[auth.totp]
|
||||
enabled = false
|
||||
|
||||
[postgres]
|
||||
version = '14.6-20230406-2'
|
||||
|
||||
[postgres.resources.storage]
|
||||
capacity = 1
|
||||
|
||||
[provider]
|
||||
|
||||
[storage]
|
||||
version = '0.3.4'
|
||||
|
||||
[observability]
|
||||
[observability.grafana]
|
||||
adminPassword = '{{ secrets.GRAFANA_ADMIN_PASSWORD }}'
|
||||
32
cli/cmd/config/testdata/validate/success/nhost/overlays/local.json
vendored
Normal file
32
cli/cmd/config/testdata/validate/success/nhost/overlays/local.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/hasura/version",
|
||||
"value": "v2.25.0-ce"
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/global/environment/0",
|
||||
"value": {
|
||||
"name": "ENVIRONMENT",
|
||||
"value": "development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/global/environment/-",
|
||||
"value": {
|
||||
"name": "FUNCTION_LOG_LEVEL",
|
||||
"value": "debug"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/auth/redirections/clientUrl",
|
||||
"value": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/method/oauth/apple"
|
||||
}
|
||||
]
|
||||
200
cli/cmd/config/validate.go
Normal file
200
cli/cmd/config/validate.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/be/services/mimir/schema"
|
||||
"github.com/nhost/be/services/mimir/schema/appconfig"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
jsonpatch "gopkg.in/evanphx/json-patch.v5"
|
||||
)
|
||||
|
||||
func CommandValidate() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "validate",
|
||||
Aliases: []string{},
|
||||
Usage: "Validate configuration",
|
||||
Action: commandValidate,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagSubdomain,
|
||||
Usage: "Validate this subdomain's configuration. Defaults to linked project",
|
||||
EnvVars: []string{"NHOST_SUBDOMAIN"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandValidate(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
subdomain := cCtx.String(flagSubdomain)
|
||||
if subdomain != "" && subdomain != "local" {
|
||||
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
_, _, err = ValidateRemote(
|
||||
cCtx.Context,
|
||||
ce,
|
||||
proj.GetSubdomain(),
|
||||
proj.GetID(),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var secrets model.Secrets
|
||||
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to parse secrets, make sure secret values are between quotes: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
ce.Infoln("Verifying configuration...")
|
||||
|
||||
if _, err := Validate(ce, "local", secrets); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ce.Infoln("Configuration is valid!")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ApplyJSONPatches[T any](
|
||||
cfg T,
|
||||
overlayPath string,
|
||||
) (*T, error) {
|
||||
f, err := os.Open(overlayPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open json patches file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
patchesb, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read json patches file: %w", err)
|
||||
}
|
||||
|
||||
cfgb, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
patch, err := jsonpatch.DecodePatch(patchesb)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to apply json patches: %w", err)
|
||||
}
|
||||
|
||||
cfgb, err = patch.Apply(cfgb)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to apply json patches: %w", err)
|
||||
}
|
||||
|
||||
var r T
|
||||
if err := json.Unmarshal(cfgb, &r); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func Validate(
|
||||
ce *clienv.CliEnv,
|
||||
subdomain string,
|
||||
secrets model.Secrets,
|
||||
) (*model.ConfigConfig, error) {
|
||||
cfg := &model.ConfigConfig{} //nolint:exhaustruct
|
||||
if err := clienv.UnmarshalFile(ce.Path.NhostToml(), cfg, toml.Unmarshal); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
if clienv.PathExists(ce.Path.Overlay(subdomain)) {
|
||||
var err error
|
||||
|
||||
cfg, err = ApplyJSONPatches(*cfg, ce.Path.Overlay(subdomain))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to apply json patches: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
schema, err := schema.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
cfg, err = appconfig.SecretsResolver(cfg, secrets, schema.Fill)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to validate config: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ValidateRemote validates the configuration of a remote project by fetching
|
||||
// the secrets and applying them to the configuration. It also applies any
|
||||
// JSON patches from the overlay directory if it exists.
|
||||
// It returns the original configuration with the applied patches (without being filled
|
||||
// and without secrets resolved) and another configuration filled and with secrets resolved.
|
||||
func ValidateRemote(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
subdomain string,
|
||||
appID string,
|
||||
) (*model.ConfigConfig, *model.ConfigConfig, error) {
|
||||
cfg := &model.ConfigConfig{} //nolint:exhaustruct
|
||||
if err := clienv.UnmarshalFile(ce.Path.NhostToml(), cfg, toml.Unmarshal); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
if clienv.PathExists(ce.Path.Overlay(subdomain)) {
|
||||
var err error
|
||||
|
||||
cfg, err = ApplyJSONPatches(*cfg, ce.Path.Overlay(subdomain))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to apply json patches: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
schema, err := schema.New()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Getting secrets...")
|
||||
|
||||
cl, err := ce.GetNhostClient(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
secretsResp, err := cl.GetSecrets(
|
||||
ctx,
|
||||
appID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get secrets: %w", err)
|
||||
}
|
||||
|
||||
secrets := respToSecrets(secretsResp.GetAppSecrets(), false)
|
||||
|
||||
cfgSecrets, err := appconfig.SecretsResolver(cfg, secrets, schema.Fill)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to validate config: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Config is valid!")
|
||||
|
||||
return cfg, cfgSecrets, nil
|
||||
}
|
||||
288
cli/cmd/config/validate_test.go
Normal file
288
cli/cmd/config/validate_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/cmd/config"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
)
|
||||
|
||||
func ptr[T any](t T) *T {
|
||||
return &t
|
||||
}
|
||||
|
||||
func expectedConfig() *model.ConfigConfig {
|
||||
//nolint:exhaustruct
|
||||
return &model.ConfigConfig{
|
||||
Global: &model.ConfigGlobal{
|
||||
Environment: []*model.ConfigGlobalEnvironmentVariable{
|
||||
{Name: "ENVIRONMENT", Value: "development"},
|
||||
{Name: "FUNCTION_LOG_LEVEL", Value: "debug"},
|
||||
},
|
||||
},
|
||||
Hasura: &model.ConfigHasura{
|
||||
Version: ptr("v2.25.0-ce"),
|
||||
JwtSecrets: []*model.ConfigJWTSecret{
|
||||
{
|
||||
Type: ptr("HS256"),
|
||||
Key: ptr("0f987876650b4a085e64594fae9219e7781b17506bec02489ad061fba8cb22db"),
|
||||
},
|
||||
},
|
||||
AdminSecret: "nhost-admin-secret",
|
||||
WebhookSecret: "nhost-webhook-secret",
|
||||
Settings: &model.ConfigHasuraSettings{
|
||||
CorsDomain: []string{"*"},
|
||||
DevMode: ptr(true),
|
||||
EnableAllowList: ptr(false),
|
||||
EnableConsole: ptr(true),
|
||||
EnableRemoteSchemaPermissions: new(bool),
|
||||
EnabledAPIs: []string{
|
||||
"metadata",
|
||||
"graphql",
|
||||
"pgdump",
|
||||
"config",
|
||||
},
|
||||
InferFunctionPermissions: ptr(true),
|
||||
LiveQueriesMultiplexedRefetchInterval: ptr(uint32(1000)),
|
||||
StringifyNumericTypes: ptr(false),
|
||||
},
|
||||
Logs: &model.ConfigHasuraLogs{Level: ptr("warn")},
|
||||
Events: &model.ConfigHasuraEvents{HttpPoolSize: ptr(uint32(100))},
|
||||
},
|
||||
Functions: &model.ConfigFunctions{Node: &model.ConfigFunctionsNode{Version: ptr(22)}},
|
||||
Auth: &model.ConfigAuth{
|
||||
Version: ptr("0.20.0"),
|
||||
Misc: &model.ConfigAuthMisc{
|
||||
ConcealErrors: ptr(false),
|
||||
},
|
||||
ElevatedPrivileges: &model.ConfigAuthElevatedPrivileges{
|
||||
Mode: ptr("disabled"),
|
||||
},
|
||||
Redirections: &model.ConfigAuthRedirections{
|
||||
ClientUrl: ptr("http://localhost:3000"),
|
||||
AllowedUrls: []string{},
|
||||
},
|
||||
SignUp: &model.ConfigAuthSignUp{
|
||||
Enabled: ptr(true),
|
||||
DisableNewUsers: ptr(false),
|
||||
},
|
||||
User: &model.ConfigAuthUser{
|
||||
Roles: &model.ConfigAuthUserRoles{
|
||||
Default: ptr("user"),
|
||||
Allowed: []string{"user", "me"},
|
||||
},
|
||||
Locale: &model.ConfigAuthUserLocale{
|
||||
Default: ptr("en"),
|
||||
Allowed: []string{"en"},
|
||||
},
|
||||
Gravatar: &model.ConfigAuthUserGravatar{
|
||||
Enabled: ptr(true),
|
||||
Default: ptr("blank"),
|
||||
Rating: ptr("g"),
|
||||
},
|
||||
Email: &model.ConfigAuthUserEmail{
|
||||
Allowed: []string{},
|
||||
Blocked: []string{},
|
||||
},
|
||||
EmailDomains: &model.ConfigAuthUserEmailDomains{
|
||||
Allowed: []string{},
|
||||
Blocked: []string{},
|
||||
},
|
||||
},
|
||||
Session: &model.ConfigAuthSession{
|
||||
AccessToken: &model.ConfigAuthSessionAccessToken{
|
||||
ExpiresIn: ptr(uint32(900)),
|
||||
CustomClaims: []*model.ConfigAuthsessionaccessTokenCustomClaims{},
|
||||
},
|
||||
RefreshToken: &model.ConfigAuthSessionRefreshToken{
|
||||
ExpiresIn: ptr(uint32(2592000)),
|
||||
},
|
||||
},
|
||||
Method: &model.ConfigAuthMethod{
|
||||
Anonymous: &model.ConfigAuthMethodAnonymous{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Otp: &model.ConfigAuthMethodOtp{
|
||||
Email: &model.ConfigAuthMethodOtpEmail{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
},
|
||||
EmailPasswordless: &model.ConfigAuthMethodEmailPasswordless{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
EmailPassword: &model.ConfigAuthMethodEmailPassword{
|
||||
HibpEnabled: ptr(false),
|
||||
EmailVerificationRequired: ptr(true),
|
||||
PasswordMinLength: ptr(uint8(9)),
|
||||
},
|
||||
SmsPasswordless: &model.ConfigAuthMethodSmsPasswordless{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Oauth: &model.ConfigAuthMethodOauth{
|
||||
Apple: &model.ConfigAuthMethodOauthApple{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Azuread: &model.ConfigAuthMethodOauthAzuread{
|
||||
Enabled: ptr(false),
|
||||
Tenant: ptr("common"),
|
||||
},
|
||||
Bitbucket: &model.ConfigStandardOauthProvider{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Discord: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Entraid: &model.ConfigAuthMethodOauthEntraid{
|
||||
Enabled: ptr(false),
|
||||
Tenant: ptr("common"),
|
||||
},
|
||||
Facebook: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Github: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Gitlab: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Google: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Linkedin: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Spotify: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Strava: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Twitch: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Twitter: &model.ConfigAuthMethodOauthTwitter{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Windowslive: &model.ConfigStandardOauthProviderWithScope{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Workos: &model.ConfigAuthMethodOauthWorkos{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
},
|
||||
Webauthn: &model.ConfigAuthMethodWebauthn{
|
||||
Enabled: ptr(false),
|
||||
RelyingParty: nil,
|
||||
Attestation: &model.ConfigAuthMethodWebauthnAttestation{
|
||||
Timeout: ptr(uint32(60000)),
|
||||
},
|
||||
},
|
||||
},
|
||||
Totp: &model.ConfigAuthTotp{Enabled: ptr(false)},
|
||||
RateLimit: &model.ConfigAuthRateLimit{
|
||||
Emails: &model.ConfigRateLimit{
|
||||
Limit: 10,
|
||||
Interval: "1h",
|
||||
},
|
||||
Sms: &model.ConfigRateLimit{
|
||||
Limit: 10,
|
||||
Interval: "1h",
|
||||
},
|
||||
BruteForce: &model.ConfigRateLimit{
|
||||
Limit: 10,
|
||||
Interval: "5m",
|
||||
},
|
||||
Signups: &model.ConfigRateLimit{
|
||||
Limit: 10,
|
||||
Interval: "5m",
|
||||
},
|
||||
Global: &model.ConfigRateLimit{
|
||||
Limit: 100,
|
||||
Interval: "1m",
|
||||
},
|
||||
},
|
||||
},
|
||||
Postgres: &model.ConfigPostgres{
|
||||
Version: ptr("14.6-20230406-2"),
|
||||
Resources: &model.ConfigPostgresResources{
|
||||
Storage: &model.ConfigPostgresResourcesStorage{
|
||||
Capacity: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
Provider: &model.ConfigProvider{},
|
||||
Storage: &model.ConfigStorage{Version: ptr("0.3.4")},
|
||||
Observability: &model.ConfigObservability{
|
||||
Grafana: &model.ConfigGrafana{
|
||||
AdminPassword: "grafana-admin-password",
|
||||
Smtp: nil,
|
||||
Alerting: &model.ConfigGrafanaAlerting{
|
||||
Enabled: ptr(false),
|
||||
},
|
||||
Contacts: &model.ConfigGrafanaContacts{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
expected func() *model.ConfigConfig
|
||||
applyPatches bool
|
||||
}{
|
||||
{
|
||||
name: "applypatches",
|
||||
path: "success",
|
||||
expected: expectedConfig,
|
||||
applyPatches: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ce := clienv.New(
|
||||
os.Stdout,
|
||||
os.Stderr,
|
||||
clienv.NewPathStructure(
|
||||
".",
|
||||
filepath.Join("testdata", "validate", tc.path),
|
||||
filepath.Join("testdata", "validate", tc.path, ".nhost"),
|
||||
filepath.Join("testdata", "validate", tc.path, "nhost"),
|
||||
),
|
||||
"fakeauthurl",
|
||||
"fakegraphqlurl",
|
||||
"fakebranch",
|
||||
"",
|
||||
"local",
|
||||
)
|
||||
|
||||
var secrets model.Secrets
|
||||
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
|
||||
t.Fatalf(
|
||||
"failed to parse secrets, make sure secret values are between quotes: %s",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cfg, err := config.Validate(ce, "local", secrets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tc.expected(), cfg); diff != "" {
|
||||
t.Errorf("%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
145
cli/cmd/configserver/configserver.go
Normal file
145
cli/cmd/configserver/configserver.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nhost/be/services/mimir/graph"
|
||||
cors "github.com/rs/cors/wrapper/gin"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
bindFlag = "bind"
|
||||
debugFlag = "debug"
|
||||
logFormatJSONFlag = "log-format-json"
|
||||
enablePlaygroundFlag = "enable-playground"
|
||||
storageLocalConfigPath = "storage-local-config-path"
|
||||
storageLocalSecretsPath = "storage-local-secrets-path"
|
||||
storageLocalRunServicesPath = "storage-local-run-services-path"
|
||||
)
|
||||
|
||||
func Command() *cli.Command {
|
||||
return &cli.Command{ //nolint: exhaustruct
|
||||
Name: "configserver",
|
||||
Usage: "serve the application",
|
||||
Hidden: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint: exhaustruct
|
||||
Name: bindFlag,
|
||||
Usage: "bind address",
|
||||
Value: ":8088",
|
||||
Category: "server",
|
||||
},
|
||||
&cli.BoolFlag{ //nolint: exhaustruct
|
||||
Name: debugFlag,
|
||||
Usage: "enable debug logging",
|
||||
Category: "general",
|
||||
},
|
||||
&cli.BoolFlag{ //nolint: exhaustruct
|
||||
Name: logFormatJSONFlag,
|
||||
Usage: "format logs in JSON",
|
||||
Category: "general",
|
||||
},
|
||||
&cli.BoolFlag{ //nolint: exhaustruct
|
||||
Name: enablePlaygroundFlag,
|
||||
Usage: "enable graphql playground (under /v1)",
|
||||
Category: "server",
|
||||
EnvVars: []string{"ENABLE_PLAYGROUND"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint: exhaustruct
|
||||
Name: storageLocalConfigPath,
|
||||
Usage: "Path to the local mimir config file",
|
||||
Value: "/tmp/root/nhost/nhost.toml",
|
||||
Category: "plugins",
|
||||
EnvVars: []string{"STORAGE_LOCAL_CONFIG_PATH"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint: exhaustruct
|
||||
Name: storageLocalSecretsPath,
|
||||
Usage: "Path to the local mimir secrets file",
|
||||
Value: "/tmp/root/.secrets",
|
||||
Category: "plugins",
|
||||
EnvVars: []string{"STORAGE_LOCAL_SECRETS_PATH"},
|
||||
},
|
||||
&cli.StringSliceFlag{ //nolint: exhaustruct
|
||||
Name: storageLocalRunServicesPath,
|
||||
Usage: "Path to the local mimir run services files",
|
||||
Category: "plugins",
|
||||
EnvVars: []string{"STORAGE_LOCAL_RUN_SERVICES_PATH"},
|
||||
},
|
||||
},
|
||||
Action: serve,
|
||||
}
|
||||
}
|
||||
|
||||
func dummyMiddleware(
|
||||
ctx context.Context,
|
||||
_ any,
|
||||
next graphql.Resolver,
|
||||
) (any, error) {
|
||||
return next(ctx)
|
||||
}
|
||||
|
||||
func dummyMiddleware2(
|
||||
ctx context.Context,
|
||||
_ any,
|
||||
next graphql.Resolver,
|
||||
_ []string,
|
||||
) (any, error) {
|
||||
return next(ctx)
|
||||
}
|
||||
|
||||
func runServicesFiles(runServices ...string) map[string]string {
|
||||
m := make(map[string]string)
|
||||
|
||||
for _, path := range runServices {
|
||||
id := uuid.NewString()
|
||||
m[id] = path
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func serve(cCtx *cli.Context) error {
|
||||
logger := getLogger(cCtx.Bool(debugFlag), cCtx.Bool(logFormatJSONFlag))
|
||||
logger.Info(cCtx.App.Name + " v" + cCtx.App.Version)
|
||||
logFlags(logger, cCtx)
|
||||
|
||||
configFile := cCtx.String(storageLocalConfigPath)
|
||||
secretsFile := cCtx.String(storageLocalSecretsPath)
|
||||
runServices := runServicesFiles(cCtx.StringSlice(storageLocalRunServicesPath)...)
|
||||
|
||||
st := NewLocal(configFile, secretsFile, runServices)
|
||||
|
||||
data, err := st.GetApps(configFile, secretsFile, runServices)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data from plugin: %w", err)
|
||||
}
|
||||
|
||||
plugins := []graph.Plugin{st}
|
||||
|
||||
resolver, err := graph.NewResolver(data, Querier{}, plugins)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create resolver: %w", err)
|
||||
}
|
||||
|
||||
r := graph.SetupRouter(
|
||||
"/v1/configserver",
|
||||
resolver,
|
||||
dummyMiddleware,
|
||||
dummyMiddleware2,
|
||||
cCtx.Bool(enablePlaygroundFlag),
|
||||
cCtx.App.Version,
|
||||
[]graphql.FieldMiddleware{},
|
||||
gin.Recovery(),
|
||||
cors.Default(),
|
||||
)
|
||||
if err := r.Run(cCtx.String(bindFlag)); err != nil {
|
||||
return fmt.Errorf("failed to run gin: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
220
cli/cmd/configserver/local.go
Normal file
220
cli/cmd/configserver/local.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/nhost/be/services/mimir/graph"
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const zeroUUID = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
var ErrNotImpl = errors.New("not implemented")
|
||||
|
||||
type Local struct {
|
||||
// we use paths instead of readers/writers because the intention is that these
|
||||
// files will be mounted as volumes in a container and if the file is changed
|
||||
// outside of the container, the filedescriptor might just be pointing to the
|
||||
// old file.
|
||||
config string
|
||||
secrets string
|
||||
runServices map[string]string
|
||||
}
|
||||
|
||||
func NewLocal(config, secrets string, runServices map[string]string) *Local {
|
||||
return &Local{
|
||||
config: config,
|
||||
secrets: secrets,
|
||||
runServices: runServices,
|
||||
}
|
||||
}
|
||||
|
||||
func unmarshal[T any](config any) (*T, error) {
|
||||
b, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("problem marshaling cue value: %w", err)
|
||||
}
|
||||
|
||||
var cfg T
|
||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("problem unmarshaling cue value: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (l *Local) GetServices(runServices map[string]string) (graph.Services, error) {
|
||||
services := make(graph.Services, 0, len(runServices))
|
||||
for id, r := range runServices {
|
||||
b, err := os.ReadFile(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read run service file: %w", err)
|
||||
}
|
||||
|
||||
var cfg model.ConfigRunServiceConfig
|
||||
if err := toml.Unmarshal(b, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal run service config: %w", err)
|
||||
}
|
||||
|
||||
services = append(services, &graph.Service{
|
||||
ServiceID: id,
|
||||
Config: &cfg,
|
||||
})
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func (l *Local) GetApps(
|
||||
configFile, secretsFile string, runServicesFiles map[string]string,
|
||||
) ([]*graph.App, error) {
|
||||
b, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
var rawCfg any
|
||||
if err := toml.Unmarshal(b, &rawCfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := unmarshal[model.ConfigConfig](rawCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fill config: %w", err)
|
||||
}
|
||||
|
||||
b, err = os.ReadFile(secretsFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read secrets file: %w", err)
|
||||
}
|
||||
|
||||
var secrets model.Secrets
|
||||
if err := env.Unmarshal(b, &secrets); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to parse secrets, make sure secret values are between quotes: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
services, err := l.GetServices(runServicesFiles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get services: %w", err)
|
||||
}
|
||||
|
||||
pgMajorVersion := "14"
|
||||
if cfg.GetPostgres().GetVersion() != nil {
|
||||
pgMajorVersion = strings.Split(*cfg.GetPostgres().GetVersion(), ".")[0]
|
||||
}
|
||||
|
||||
return []*graph.App{
|
||||
{
|
||||
Config: cfg,
|
||||
SystemConfig: &model.ConfigSystemConfig{ //nolint:exhaustruct
|
||||
Postgres: &model.ConfigSystemConfigPostgres{ //nolint:exhaustruct
|
||||
MajorVersion: &pgMajorVersion,
|
||||
Database: "local",
|
||||
ConnectionString: &model.ConfigSystemConfigPostgresConnectionString{
|
||||
Backup: "a",
|
||||
Hasura: "a",
|
||||
Auth: "a",
|
||||
Storage: "a",
|
||||
},
|
||||
},
|
||||
},
|
||||
Secrets: secrets,
|
||||
Services: services,
|
||||
AppID: zeroUUID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *Local) CreateApp(_ context.Context, _ *graph.App, _ logrus.FieldLogger) error {
|
||||
return ErrNotImpl
|
||||
}
|
||||
|
||||
func (l *Local) DeleteApp(_ context.Context, _ *graph.App, _ logrus.FieldLogger) error {
|
||||
return ErrNotImpl
|
||||
}
|
||||
|
||||
func (l *Local) UpdateConfig(_ context.Context, _, newApp *graph.App, _ logrus.FieldLogger) error {
|
||||
b, err := toml.Marshal(newApp.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal app config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(l.config, b, 0o644); err != nil { //nolint:gosec,mnd
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Local) UpdateSystemConfig(_ context.Context, _, _ *graph.App, _ logrus.FieldLogger) error {
|
||||
return ErrNotImpl
|
||||
}
|
||||
|
||||
func (l *Local) UpdateSecrets(_ context.Context, _, newApp *graph.App, _ logrus.FieldLogger) error {
|
||||
m := make(map[string]string)
|
||||
for _, v := range newApp.Secrets {
|
||||
m[v.Name] = v.Value
|
||||
}
|
||||
|
||||
b, err := toml.Marshal(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal app secrets: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(l.secrets, b, 0o644); err != nil { //nolint:gosec,mnd
|
||||
return fmt.Errorf("failed to write secrets: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Local) CreateRunServiceConfig(
|
||||
_ context.Context, _ string, _ *graph.Service, _ logrus.FieldLogger,
|
||||
) error {
|
||||
return ErrNotImpl
|
||||
}
|
||||
|
||||
func (l *Local) UpdateRunServiceConfig(
|
||||
_ context.Context, _ string, _, newSvc *graph.Service, _ logrus.FieldLogger,
|
||||
) error {
|
||||
wr, ok := l.runServices[newSvc.ServiceID]
|
||||
if !ok {
|
||||
return fmt.Errorf("run service not found: %s", newSvc.ServiceID) //nolint:err113
|
||||
}
|
||||
|
||||
b, err := toml.Marshal(newSvc.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal run service config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(wr, b, 0o644); err != nil { //nolint:gosec,mnd
|
||||
return fmt.Errorf("failed to write run service config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Local) DeleteRunServiceConfig(
|
||||
_ context.Context, _ string, _ *graph.Service, _ logrus.FieldLogger,
|
||||
) error {
|
||||
return ErrNotImpl
|
||||
}
|
||||
|
||||
func (l *Local) ChangeDatabaseVersion(
|
||||
_ context.Context,
|
||||
_, _ *graph.App,
|
||||
_ logrus.FieldLogger,
|
||||
) error {
|
||||
return ErrNotImpl
|
||||
}
|
||||
286
cli/cmd/configserver/local_test.go
Normal file
286
cli/cmd/configserver/local_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package configserver_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/nhost/be/services/mimir/graph"
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/cmd/configserver"
|
||||
)
|
||||
|
||||
const rawConfig = `[hasura]
|
||||
adminSecret = 'hasuraAdminSecret'
|
||||
webhookSecret = 'webhookSecret'
|
||||
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = 'asdasdasdasd'
|
||||
|
||||
[observability]
|
||||
[observability.grafana]
|
||||
adminPassword = 'asdasd'
|
||||
`
|
||||
|
||||
const rawSecrets = `someSecret = 'asdasd'
|
||||
`
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func newApp() *graph.App {
|
||||
return &graph.App{
|
||||
Config: &model.ConfigConfig{
|
||||
Global: nil,
|
||||
Graphql: nil,
|
||||
Hasura: &model.ConfigHasura{ //nolint:exhaustruct
|
||||
AdminSecret: "hasuraAdminSecret",
|
||||
WebhookSecret: "webhookSecret",
|
||||
JwtSecrets: []*model.ConfigJWTSecret{
|
||||
{
|
||||
Type: ptr("HS256"),
|
||||
Key: ptr("asdasdasdasd"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Functions: nil,
|
||||
Auth: nil,
|
||||
Postgres: nil,
|
||||
Provider: nil,
|
||||
Storage: nil,
|
||||
Ai: nil,
|
||||
Observability: &model.ConfigObservability{
|
||||
Grafana: &model.ConfigGrafana{
|
||||
AdminPassword: "asdasd",
|
||||
Smtp: nil,
|
||||
Alerting: nil,
|
||||
Contacts: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
SystemConfig: &model.ConfigSystemConfig{ //nolint:exhaustruct
|
||||
Postgres: &model.ConfigSystemConfigPostgres{ //nolint:exhaustruct
|
||||
MajorVersion: ptr("14"),
|
||||
Database: "local",
|
||||
ConnectionString: &model.ConfigSystemConfigPostgresConnectionString{
|
||||
Backup: "a",
|
||||
Hasura: "a",
|
||||
Auth: "a",
|
||||
Storage: "a",
|
||||
},
|
||||
},
|
||||
},
|
||||
Secrets: []*model.ConfigEnvironmentVariable{
|
||||
{
|
||||
Name: "someSecret",
|
||||
Value: "asdasd",
|
||||
},
|
||||
},
|
||||
Services: graph.Services{},
|
||||
AppID: "00000000-0000-0000-0000-000000000000",
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalGetApps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
configRaw string
|
||||
secretsRaw string
|
||||
expected []*graph.App
|
||||
}{
|
||||
{
|
||||
name: "works",
|
||||
configRaw: rawConfig,
|
||||
secretsRaw: rawSecrets,
|
||||
expected: []*graph.App{newApp()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configF, err := os.CreateTemp(t.TempDir(), "TestLocalGetApps")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(configF.Name())
|
||||
|
||||
if _, err := configF.WriteString(tc.configRaw); err != nil {
|
||||
t.Fatalf("failed to write to temp file: %v", err)
|
||||
}
|
||||
|
||||
secretsF, err := os.CreateTemp(t.TempDir(), "TestLocalGetApps")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(secretsF.Name())
|
||||
|
||||
if _, err := secretsF.WriteString(tc.secretsRaw); err != nil {
|
||||
t.Fatalf("failed to write to temp file: %v", err)
|
||||
}
|
||||
|
||||
st := configserver.NewLocal(
|
||||
configF.Name(),
|
||||
secretsF.Name(),
|
||||
nil,
|
||||
)
|
||||
|
||||
got, err := st.GetApps(configF.Name(), secretsF.Name(), nil)
|
||||
if err != nil {
|
||||
t.Errorf("GetApps() got error: %v", err)
|
||||
}
|
||||
|
||||
cmpOpts := cmpopts.IgnoreUnexported(graph.App{}) //nolint:exhaustruct
|
||||
|
||||
if diff := cmp.Diff(tc.expected, got, cmpOpts); diff != "" {
|
||||
t.Errorf("GetApps() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalUpdateConfig(t *testing.T) { //nolint:dupl
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
configRaw string
|
||||
secretsRaw string
|
||||
newApp *graph.App
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "works",
|
||||
configRaw: rawConfig,
|
||||
secretsRaw: rawSecrets,
|
||||
newApp: newApp(),
|
||||
expected: rawConfig,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configF, err := os.CreateTemp(t.TempDir(), "TestLocalGetApps")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(configF.Name())
|
||||
|
||||
if _, err := configF.WriteString(tc.configRaw); err != nil {
|
||||
t.Fatalf("failed to write to temp file: %v", err)
|
||||
}
|
||||
|
||||
secretsF, err := os.CreateTemp(t.TempDir(), "TestLocalGetApps")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(secretsF.Name())
|
||||
|
||||
if _, err := secretsF.WriteString(tc.secretsRaw); err != nil {
|
||||
t.Fatalf("failed to write to temp file: %v", err)
|
||||
}
|
||||
|
||||
st := configserver.NewLocal(
|
||||
configF.Name(),
|
||||
secretsF.Name(),
|
||||
nil,
|
||||
)
|
||||
|
||||
if err := st.UpdateConfig(
|
||||
t.Context(),
|
||||
nil,
|
||||
tc.newApp,
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Errorf("UpdateConfig() got error: %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(configF.Name())
|
||||
if err != nil {
|
||||
t.Errorf("failed to read config file: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tc.expected, string(b)); diff != "" {
|
||||
t.Errorf("UpdateConfig() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalUpdateSecrets(t *testing.T) { //nolint:dupl
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
configRaw string
|
||||
secretsRaw string
|
||||
newApp *graph.App
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "works",
|
||||
configRaw: rawConfig,
|
||||
secretsRaw: rawSecrets,
|
||||
newApp: newApp(),
|
||||
expected: rawSecrets,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configF, err := os.CreateTemp(t.TempDir(), "TestLocalGetApps")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(configF.Name())
|
||||
|
||||
if _, err := configF.WriteString(tc.configRaw); err != nil {
|
||||
t.Fatalf("failed to write to temp file: %v", err)
|
||||
}
|
||||
|
||||
secretsF, err := os.CreateTemp(t.TempDir(), "TestLocalGetApps")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(secretsF.Name())
|
||||
|
||||
if _, err := secretsF.WriteString(tc.secretsRaw); err != nil {
|
||||
t.Fatalf("failed to write to temp file: %v", err)
|
||||
}
|
||||
|
||||
st := configserver.NewLocal(
|
||||
configF.Name(),
|
||||
secretsF.Name(),
|
||||
nil,
|
||||
)
|
||||
|
||||
if err := st.UpdateSecrets(
|
||||
t.Context(),
|
||||
nil,
|
||||
tc.newApp,
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Errorf("UpdateSecrets() got error: %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(secretsF.Name())
|
||||
if err != nil {
|
||||
t.Errorf("failed to read config file: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tc.expected, string(b)); diff != "" {
|
||||
t.Errorf("UpdateSecrets() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
54
cli/cmd/configserver/logger.go
Normal file
54
cli/cmd/configserver/logger.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func getLogger(debug bool, formatJSON bool) *logrus.Logger {
|
||||
logger := logrus.New()
|
||||
if formatJSON {
|
||||
logger.Formatter = &logrus.JSONFormatter{} //nolint: exhaustruct
|
||||
} else {
|
||||
logger.SetFormatter(&logrus.TextFormatter{ //nolint: exhaustruct
|
||||
FullTimestamp: true,
|
||||
})
|
||||
}
|
||||
|
||||
if debug {
|
||||
logger.SetLevel(logrus.DebugLevel)
|
||||
gin.SetMode(gin.DebugMode)
|
||||
} else {
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
func logFlags(logger logrus.FieldLogger, cCtx *cli.Context) {
|
||||
fields := logrus.Fields{}
|
||||
|
||||
for _, flag := range cCtx.App.Flags {
|
||||
name := flag.Names()[0]
|
||||
fields[name] = cCtx.Generic(name)
|
||||
}
|
||||
|
||||
for _, flag := range cCtx.Command.Flags {
|
||||
name := flag.Names()[0]
|
||||
if strings.Contains(name, "pass") ||
|
||||
strings.Contains(name, "token") ||
|
||||
strings.Contains(name, "secret") ||
|
||||
strings.Contains(name, "key") {
|
||||
fields[name] = "******"
|
||||
continue
|
||||
}
|
||||
|
||||
fields[name] = cCtx.Generic(name)
|
||||
}
|
||||
|
||||
logger.WithFields(fields).Info("started with settings")
|
||||
}
|
||||
13
cli/cmd/configserver/querier.go
Normal file
13
cli/cmd/configserver/querier.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Querier struct{}
|
||||
|
||||
func (q Querier) GetAppDesiredState(_ context.Context, _ uuid.UUID) (int32, error) {
|
||||
return 0, nil
|
||||
}
|
||||
28
cli/cmd/deployments/deployments.go
Normal file
28
cli/cmd/deployments/deployments.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package deployments
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
const flagSubdomain = "subdomain"
|
||||
|
||||
func commonFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagSubdomain,
|
||||
Usage: "Project's subdomain to operate on, defaults to linked project",
|
||||
EnvVars: []string{"NHOST_SUBDOMAIN"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Command() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "deployments",
|
||||
Aliases: []string{},
|
||||
Usage: "Manage deployments",
|
||||
Subcommands: []*cli.Command{
|
||||
CommandList(),
|
||||
CommandLogs(),
|
||||
CommandNew(),
|
||||
},
|
||||
}
|
||||
}
|
||||
104
cli/cmd/deployments/list.go
Normal file
104
cli/cmd/deployments/list.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package deployments
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/nhostclient/graphql"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandList() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "list",
|
||||
Aliases: []string{},
|
||||
Usage: "List deployments in the cloud environment",
|
||||
Action: commandList,
|
||||
Flags: commonFlags(),
|
||||
}
|
||||
}
|
||||
|
||||
func printDeployments(ce *clienv.CliEnv, deployments []*graphql.ListDeployments_Deployments) {
|
||||
id := clienv.Column{
|
||||
Header: "ID",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
date := clienv.Column{
|
||||
Header: "Date",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
duration := clienv.Column{
|
||||
Header: "Duration",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
status := clienv.Column{
|
||||
Header: "Status",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
user := clienv.Column{
|
||||
Header: "User",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
ref := clienv.Column{
|
||||
Header: "Ref",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
message := clienv.Column{
|
||||
Header: "Message",
|
||||
Rows: make([]string, 0),
|
||||
}
|
||||
|
||||
for _, d := range deployments {
|
||||
var startedAt time.Time
|
||||
if d.DeploymentStartedAt != nil && !d.DeploymentStartedAt.IsZero() {
|
||||
startedAt = *d.DeploymentStartedAt
|
||||
}
|
||||
|
||||
var (
|
||||
endedAt time.Time
|
||||
deplPuration time.Duration
|
||||
)
|
||||
|
||||
if d.DeploymentEndedAt != nil && !d.DeploymentEndedAt.IsZero() {
|
||||
endedAt = *d.DeploymentEndedAt
|
||||
deplPuration = endedAt.Sub(startedAt)
|
||||
}
|
||||
|
||||
id.Rows = append(id.Rows, d.ID)
|
||||
date.Rows = append(date.Rows, startedAt.Format(time.RFC3339))
|
||||
duration.Rows = append(duration.Rows, deplPuration.String())
|
||||
status.Rows = append(status.Rows, *d.DeploymentStatus)
|
||||
user.Rows = append(user.Rows, *d.CommitUserName)
|
||||
ref.Rows = append(ref.Rows, d.CommitSha)
|
||||
message.Rows = append(message.Rows, *d.CommitMessage)
|
||||
}
|
||||
|
||||
ce.Println("%s", clienv.Table(id, date, duration, status, user, ref, message))
|
||||
}
|
||||
|
||||
func commandList(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
cl, err := ce.GetNhostClient(cCtx.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
deployments, err := cl.ListDeployments(
|
||||
cCtx.Context,
|
||||
proj.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get deployments: %w", err)
|
||||
}
|
||||
|
||||
printDeployments(ce, deployments.GetDeployments())
|
||||
|
||||
return nil
|
||||
}
|
||||
131
cli/cmd/deployments/logs.go
Normal file
131
cli/cmd/deployments/logs.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package deployments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/nhostclient"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagFollow = "follow"
|
||||
flagTimeout = "timeout"
|
||||
)
|
||||
|
||||
func CommandLogs() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "logs",
|
||||
Aliases: []string{},
|
||||
Usage: "View deployments logs in the cloud environment",
|
||||
Action: commandLogs,
|
||||
ArgsUsage: "<deployment_id>",
|
||||
Flags: append(
|
||||
commonFlags(),
|
||||
[]cli.Flag{
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagFollow,
|
||||
Usage: "Specify if the logs should be streamed",
|
||||
Value: false,
|
||||
},
|
||||
&cli.DurationFlag{ //nolint:exhaustruct
|
||||
Name: flagTimeout,
|
||||
Usage: "Specify the timeout for streaming logs",
|
||||
Value: time.Minute * 5, //nolint:mnd
|
||||
},
|
||||
}...,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func showLogsSimple(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
cl *nhostclient.Client,
|
||||
deploymentID string,
|
||||
) error {
|
||||
resp, err := cl.GetDeploymentLogs(ctx, deploymentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get deployments: %w", err)
|
||||
}
|
||||
|
||||
for _, log := range resp.GetDeploymentLogs() {
|
||||
ce.Println(
|
||||
"%s %s",
|
||||
log.GetCreatedAt().Format(time.RFC3339),
|
||||
log.GetMessage(),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func showLogsFollow(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
cl *nhostclient.Client,
|
||||
deploymentID string,
|
||||
) (string, error) {
|
||||
ticker := time.NewTicker(time.Second * 2) //nolint:mnd
|
||||
|
||||
printed := make(map[string]struct{})
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", nil
|
||||
case <-ticker.C:
|
||||
resp, err := cl.GetDeploymentLogs(ctx, deploymentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get deployments: %w", err)
|
||||
}
|
||||
|
||||
for _, log := range resp.GetDeploymentLogs() {
|
||||
if _, ok := printed[log.GetID()]; !ok {
|
||||
ce.Println(
|
||||
"%s %s",
|
||||
log.GetCreatedAt().Format(time.RFC3339),
|
||||
log.GetMessage(),
|
||||
)
|
||||
printed[log.GetID()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Deployment.DeploymentEndedAt != nil {
|
||||
return *resp.Deployment.DeploymentStatus, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func commandLogs(cCtx *cli.Context) error {
|
||||
deploymentID := cCtx.Args().First()
|
||||
if deploymentID == "" {
|
||||
return errors.New("deployment_id is required") //nolint:err113
|
||||
}
|
||||
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
cl, err := ce.GetNhostClient(cCtx.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
if cCtx.Bool(flagFollow) {
|
||||
ctx, cancel := context.WithTimeout(cCtx.Context, cCtx.Duration(flagTimeout))
|
||||
defer cancel()
|
||||
|
||||
if _, err := showLogsFollow(ctx, ce, cl, deploymentID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := showLogsSimple(cCtx.Context, ce, cl, deploymentID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
118
cli/cmd/deployments/new.go
Normal file
118
cli/cmd/deployments/new.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package deployments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/nhostclient/graphql"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagRef = "ref"
|
||||
flagMessage = "message"
|
||||
flagUser = "user"
|
||||
flagUserAvatarURL = "user-avatar-url"
|
||||
)
|
||||
|
||||
func CommandNew() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "new",
|
||||
Aliases: []string{},
|
||||
Usage: "[EXPERIMENTAL] Create a new deployment",
|
||||
ArgsUsage: "<git_ref>",
|
||||
Action: commandNew,
|
||||
Flags: append(
|
||||
commonFlags(),
|
||||
[]cli.Flag{
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagFollow,
|
||||
Usage: "Specify if the logs should be streamed. If set, the command will wait for the deployment to finish and stream the logs. If the deployment fails the command will return an error.", //nolint:lll
|
||||
Value: false,
|
||||
},
|
||||
&cli.DurationFlag{ //nolint:exhaustruct
|
||||
Name: flagTimeout,
|
||||
Usage: "Specify the timeout for streaming logs",
|
||||
Value: time.Minute * 5, //nolint:mnd
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagRef,
|
||||
Usage: "Git reference",
|
||||
EnvVars: []string{"GITHUB_SHA"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagMessage,
|
||||
Usage: "Commit message",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagUser,
|
||||
Usage: "Commit user name",
|
||||
EnvVars: []string{"GITHUB_ACTOR"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagUserAvatarURL,
|
||||
Usage: "Commit user avatar URL",
|
||||
},
|
||||
}...,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[i any](v i) *i {
|
||||
return &v
|
||||
}
|
||||
|
||||
func commandNew(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
cl, err := ce.GetNhostClient(cCtx.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
resp, err := cl.InsertDeployment(
|
||||
cCtx.Context,
|
||||
graphql.DeploymentsInsertInput{
|
||||
App: nil,
|
||||
AppID: ptr(proj.ID),
|
||||
CommitMessage: ptr(cCtx.String(flagMessage)),
|
||||
CommitSha: ptr(cCtx.String(flagRef)),
|
||||
CommitUserAvatarURL: ptr(cCtx.String(flagUserAvatarURL)),
|
||||
CommitUserName: ptr(cCtx.String(flagUser)),
|
||||
DeploymentStatus: ptr("SCHEDULED"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert deployment: %w", err)
|
||||
}
|
||||
|
||||
ce.Println("Deployment created: %s", resp.InsertDeployment.ID)
|
||||
|
||||
if cCtx.Bool(flagFollow) {
|
||||
ce.Println("")
|
||||
|
||||
ctx, cancel := context.WithTimeout(cCtx.Context, cCtx.Duration(flagTimeout))
|
||||
defer cancel()
|
||||
|
||||
status, err := showLogsFollow(ctx, ce, cl, resp.InsertDeployment.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error streaming logs: %w", err)
|
||||
}
|
||||
|
||||
if status != "DEPLOYED" {
|
||||
return fmt.Errorf("deployment failed: %s", status) //nolint:err113
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
313
cli/cmd/dev/cloud.go
Normal file
313
cli/cmd/dev/cloud.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package dev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/cmd/config"
|
||||
"github.com/nhost/nhost/cli/cmd/software"
|
||||
"github.com/nhost/nhost/cli/dockercompose"
|
||||
"github.com/nhost/nhost/cli/nhostclient/graphql"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagSubdomain = "subdomain"
|
||||
flagPostgresURL = "postgres-url"
|
||||
)
|
||||
|
||||
func CommandCloud() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "cloud",
|
||||
Aliases: []string{},
|
||||
Usage: "Start local development environment connected to an Nhost Cloud project (BETA)",
|
||||
Action: commandCloud,
|
||||
Flags: []cli.Flag{
|
||||
&cli.UintFlag{ //nolint:exhaustruct
|
||||
Name: flagHTTPPort,
|
||||
Usage: "HTTP port to listen on",
|
||||
Value: defaultHTTPPort,
|
||||
EnvVars: []string{"NHOST_HTTP_PORT"},
|
||||
},
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagDisableTLS,
|
||||
Usage: "Disable TLS",
|
||||
Value: false,
|
||||
EnvVars: []string{"NHOST_DISABLE_TLS"},
|
||||
},
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagApplySeeds,
|
||||
Usage: "Apply seeds. If the .nhost folder does not exist, seeds will be applied regardless of this flag",
|
||||
Value: false,
|
||||
EnvVars: []string{"NHOST_APPLY_SEEDS"},
|
||||
},
|
||||
&cli.UintFlag{ //nolint:exhaustruct
|
||||
Name: flagsHasuraConsolePort,
|
||||
Usage: "If specified, expose hasura console on this port. Not recommended",
|
||||
Value: 0,
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagDashboardVersion,
|
||||
Usage: "Dashboard version to use",
|
||||
Value: "nhost/dashboard:2.33.0",
|
||||
EnvVars: []string{"NHOST_DASHBOARD_VERSION"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagConfigserverImage,
|
||||
Hidden: true,
|
||||
Value: "",
|
||||
EnvVars: []string{"NHOST_CONFIGSERVER_IMAGE"},
|
||||
},
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagDownOnError,
|
||||
Usage: "Skip confirmation",
|
||||
EnvVars: []string{"NHOST_YES"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagCACertificates,
|
||||
Usage: "Mounts and everrides path to CA certificates in the containers",
|
||||
EnvVars: []string{"NHOST_CA_CERTIFICATES"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagSubdomain,
|
||||
Usage: "Project's subdomain to operate on, defaults to linked project",
|
||||
EnvVars: []string{"NHOST_SUBDOMAIN"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagPostgresURL,
|
||||
Usage: "Postgres URL",
|
||||
Required: true,
|
||||
EnvVars: []string{"NHOST_POSTGRES_URL"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandCloud(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
if !clienv.PathExists(ce.Path.NhostToml()) {
|
||||
return errors.New( //nolint:err113
|
||||
"no nhost project found, please run `nhost init` or `nhost config pull`",
|
||||
)
|
||||
}
|
||||
|
||||
if !clienv.PathExists(ce.Path.Secrets()) {
|
||||
return errors.New( //nolint:err113
|
||||
"no secrets found, please run `nhost init` or `nhost config pull`",
|
||||
)
|
||||
}
|
||||
|
||||
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
configserverImage := cCtx.String(flagConfigserverImage)
|
||||
if configserverImage == "" {
|
||||
configserverImage = "nhost/cli:" + cCtx.App.Version
|
||||
}
|
||||
|
||||
applySeeds := cCtx.Bool(flagApplySeeds)
|
||||
|
||||
return Cloud(
|
||||
cCtx.Context,
|
||||
ce,
|
||||
cCtx.App.Version,
|
||||
cCtx.Uint(flagHTTPPort),
|
||||
!cCtx.Bool(flagDisableTLS),
|
||||
applySeeds,
|
||||
dockercompose.ExposePorts{
|
||||
Auth: cCtx.Uint(flagAuthPort),
|
||||
Storage: cCtx.Uint(flagStoragePort),
|
||||
Graphql: cCtx.Uint(flagsHasuraPort),
|
||||
Console: cCtx.Uint(flagsHasuraConsolePort),
|
||||
Functions: cCtx.Uint(flagsFunctionsPort),
|
||||
},
|
||||
cCtx.String(flagDashboardVersion),
|
||||
configserverImage,
|
||||
cCtx.String(flagCACertificates),
|
||||
cCtx.Bool(flagDownOnError),
|
||||
proj,
|
||||
cCtx.String(flagPostgresURL),
|
||||
)
|
||||
}
|
||||
|
||||
func cloud( //nolint:funlen
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
appVersion string,
|
||||
dc *dockercompose.DockerCompose,
|
||||
httpPort uint,
|
||||
useTLS bool,
|
||||
applySeeds bool,
|
||||
ports dockercompose.ExposePorts,
|
||||
dashboardVersion string,
|
||||
configserverImage string,
|
||||
caCertificatesPath string,
|
||||
proj *graphql.AppSummaryFragment,
|
||||
postgresURL string,
|
||||
) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
cancel()
|
||||
}()
|
||||
|
||||
ce.Infoln("Validating configuration...")
|
||||
|
||||
cfg, cfgSecrets, err := config.ValidateRemote(
|
||||
ctx,
|
||||
ce,
|
||||
proj.GetSubdomain(),
|
||||
proj.GetID(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate configuration: %w", err)
|
||||
}
|
||||
|
||||
ctxWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second) //nolint:mnd
|
||||
defer cancel()
|
||||
|
||||
ce.Infoln("Checking versions...")
|
||||
|
||||
if err := software.CheckVersions(ctxWithTimeout, ce, cfgSecrets, appVersion); err != nil {
|
||||
ce.Warnln("Problem verifying recommended versions: %s", err.Error())
|
||||
}
|
||||
|
||||
ce.Infoln("Setting up Nhost development environment...")
|
||||
|
||||
composeFile, err := dockercompose.CloudComposeFileFromConfig(
|
||||
cfgSecrets,
|
||||
ce.LocalSubdomain(),
|
||||
proj.GetSubdomain(),
|
||||
proj.GetRegion().GetName(),
|
||||
cfgSecrets.Hasura.GetAdminSecret(),
|
||||
postgresURL,
|
||||
ce.ProjectName(),
|
||||
httpPort,
|
||||
useTLS,
|
||||
ce.Path.NhostFolder(),
|
||||
ce.Path.DotNhostFolder(),
|
||||
ce.Path.Root(),
|
||||
ports,
|
||||
dashboardVersion,
|
||||
configserverImage,
|
||||
caCertificatesPath,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate docker-compose.yaml: %w", err)
|
||||
}
|
||||
|
||||
if err := dc.WriteComposeFile(composeFile); err != nil {
|
||||
return fmt.Errorf("failed to write docker-compose.yaml: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Starting Nhost development environment...")
|
||||
|
||||
if err = dc.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to start Nhost development environment: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Applying configuration to Nhost Cloud project...")
|
||||
|
||||
if err = config.Apply(ctx, ce, proj.GetID(), cfg, true); err != nil {
|
||||
return fmt.Errorf("failed to apply configuration: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf(
|
||||
"https://%s.hasura.%s.nhost.run",
|
||||
proj.GetSubdomain(), proj.GetRegion().GetName(),
|
||||
)
|
||||
|
||||
if err := migrations(ctx, ce, dc, endpoint, applySeeds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
docker := dockercompose.NewDocker()
|
||||
|
||||
ce.Infoln("Downloading metadata...")
|
||||
|
||||
if err := docker.HasuraWrapper(
|
||||
ctx,
|
||||
ce.LocalSubdomain(),
|
||||
ce.Path.NhostFolder(),
|
||||
*cfgSecrets.Hasura.Version,
|
||||
"metadata", "export",
|
||||
"--skip-update-check",
|
||||
"--log-level", "ERROR",
|
||||
"--endpoint", endpoint,
|
||||
"--admin-secret", cfgSecrets.Hasura.GetAdminSecret(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to create metadata: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Nhost development environment started.")
|
||||
printCloudInfo(ce.LocalSubdomain(), httpPort, useTLS)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printCloudInfo(
|
||||
subdomain string,
|
||||
httpPort uint,
|
||||
useTLS bool,
|
||||
) {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) //nolint:mnd
|
||||
fmt.Fprintf(w, "URLs:\n")
|
||||
fmt.Fprintf(w, "- Console:\t\t%s\n", dockercompose.URL(
|
||||
subdomain, "hasura", httpPort, useTLS))
|
||||
fmt.Fprintf(w, "- Dashboard:\t\t%s\n", dockercompose.URL(
|
||||
subdomain, "dashboard", httpPort, useTLS))
|
||||
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func Cloud(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
appVersion string,
|
||||
httpPort uint,
|
||||
useTLS bool,
|
||||
applySeeds bool,
|
||||
ports dockercompose.ExposePorts,
|
||||
dashboardVersion string,
|
||||
configserverImage string,
|
||||
caCertificatesPath string,
|
||||
downOnError bool,
|
||||
proj *graphql.AppSummaryFragment,
|
||||
postgresURL string,
|
||||
) error {
|
||||
dc := dockercompose.New(ce.Path.WorkingDir(), ce.Path.DockerCompose(), ce.ProjectName())
|
||||
|
||||
if err := cloud(
|
||||
ctx,
|
||||
ce,
|
||||
appVersion,
|
||||
dc,
|
||||
httpPort,
|
||||
useTLS,
|
||||
applySeeds,
|
||||
ports,
|
||||
dashboardVersion,
|
||||
configserverImage,
|
||||
caCertificatesPath,
|
||||
proj,
|
||||
postgresURL,
|
||||
); err != nil {
|
||||
return upErr(ce, dc, downOnError, err) //nolint:contextcheck
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
25
cli/cmd/dev/compose.go
Normal file
25
cli/cmd/dev/compose.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package dev
|
||||
|
||||
import (
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/dockercompose"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandCompose() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "compose",
|
||||
Aliases: []string{},
|
||||
Usage: "docker compose wrapper, sets project name and compose file automatically",
|
||||
Action: commandCompose,
|
||||
Flags: []cli.Flag{},
|
||||
SkipFlagParsing: true,
|
||||
}
|
||||
}
|
||||
|
||||
func commandCompose(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
dc := dockercompose.New(ce.Path.WorkingDir(), ce.Path.DockerCompose(), ce.ProjectName())
|
||||
|
||||
return dc.Wrapper(cCtx.Context, cCtx.Args().Slice()...) //nolint:wrapcheck
|
||||
}
|
||||
15
cli/cmd/dev/dev.go
Normal file
15
cli/cmd/dev/dev.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package dev
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func Command() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "dev",
|
||||
Aliases: []string{},
|
||||
Usage: "Operate local development environment",
|
||||
Subcommands: []*cli.Command{
|
||||
CommandCompose(),
|
||||
CommandHasura(),
|
||||
},
|
||||
}
|
||||
}
|
||||
39
cli/cmd/dev/down.go
Normal file
39
cli/cmd/dev/down.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package dev
|
||||
|
||||
import (
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/dockercompose"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagVolumes = "volumes"
|
||||
)
|
||||
|
||||
func CommandDown() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "down",
|
||||
Aliases: []string{},
|
||||
Usage: "Stop local development environment",
|
||||
Action: commandDown,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagVolumes,
|
||||
Usage: "Remove volumes",
|
||||
Value: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandDown(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
dc := dockercompose.New(ce.Path.WorkingDir(), ce.Path.DockerCompose(), ce.ProjectName())
|
||||
|
||||
if err := dc.Stop(cCtx.Context, cCtx.Bool(flagVolumes)); err != nil {
|
||||
ce.Warnln("failed to stop Nhost development environment: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
41
cli/cmd/dev/hasura.go
Normal file
41
cli/cmd/dev/hasura.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package dev
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/dockercompose"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandHasura() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "hasura",
|
||||
Aliases: []string{},
|
||||
Usage: "hasura-cli wrapper",
|
||||
Action: commandHasura,
|
||||
Flags: []cli.Flag{},
|
||||
SkipFlagParsing: true,
|
||||
}
|
||||
}
|
||||
|
||||
func commandHasura(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
cfg := &model.ConfigConfig{} //nolint:exhaustruct
|
||||
if err := clienv.UnmarshalFile(ce.Path.NhostToml(), cfg, toml.Unmarshal); err != nil {
|
||||
return fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
docker := dockercompose.NewDocker()
|
||||
|
||||
return docker.HasuraWrapper( //nolint:wrapcheck
|
||||
cCtx.Context,
|
||||
ce.LocalSubdomain(),
|
||||
ce.Path.NhostFolder(),
|
||||
*cfg.Hasura.Version,
|
||||
cCtx.Args().Slice()...,
|
||||
)
|
||||
}
|
||||
30
cli/cmd/dev/logs.go
Normal file
30
cli/cmd/dev/logs.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package dev
|
||||
|
||||
import (
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/dockercompose"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandLogs() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "logs",
|
||||
Aliases: []string{},
|
||||
Usage: "Show logs from local development environment",
|
||||
Action: commandLogs,
|
||||
Flags: []cli.Flag{},
|
||||
SkipFlagParsing: true,
|
||||
}
|
||||
}
|
||||
|
||||
func commandLogs(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
dc := dockercompose.New(ce.Path.WorkingDir(), ce.Path.DockerCompose(), ce.ProjectName())
|
||||
|
||||
if err := dc.Logs(cCtx.Context, cCtx.Args().Slice()...); err != nil {
|
||||
ce.Warnln("%s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
579
cli/cmd/dev/up.go
Normal file
579
cli/cmd/dev/up.go
Normal file
@@ -0,0 +1,579 @@
|
||||
package dev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/cmd/config"
|
||||
"github.com/nhost/nhost/cli/cmd/run"
|
||||
"github.com/nhost/nhost/cli/cmd/software"
|
||||
"github.com/nhost/nhost/cli/dockercompose"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func deptr[T any](t *T) T { //nolint:ireturn
|
||||
if t == nil {
|
||||
return *new(T)
|
||||
}
|
||||
|
||||
return *t
|
||||
}
|
||||
|
||||
const (
|
||||
flagHTTPPort = "http-port"
|
||||
flagDisableTLS = "disable-tls"
|
||||
flagPostgresPort = "postgres-port"
|
||||
flagApplySeeds = "apply-seeds"
|
||||
flagAuthPort = "auth-port"
|
||||
flagStoragePort = "storage-port"
|
||||
flagsFunctionsPort = "functions-port"
|
||||
flagsHasuraPort = "hasura-port"
|
||||
flagsHasuraConsolePort = "hasura-console-port"
|
||||
flagDashboardVersion = "dashboard-version"
|
||||
flagConfigserverImage = "configserver-image"
|
||||
flagRunService = "run-service"
|
||||
flagDownOnError = "down-on-error"
|
||||
flagCACertificates = "ca-certificates"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPPort = 443
|
||||
defaultPostgresPort = 5432
|
||||
)
|
||||
|
||||
func CommandUp() *cli.Command { //nolint:funlen
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "up",
|
||||
Aliases: []string{},
|
||||
Usage: "Start local development environment",
|
||||
Action: commandUp,
|
||||
Flags: []cli.Flag{
|
||||
&cli.UintFlag{ //nolint:exhaustruct
|
||||
Name: flagHTTPPort,
|
||||
Usage: "HTTP port to listen on",
|
||||
Value: defaultHTTPPort,
|
||||
EnvVars: []string{"NHOST_HTTP_PORT"},
|
||||
},
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagDisableTLS,
|
||||
Usage: "Disable TLS",
|
||||
Value: false,
|
||||
EnvVars: []string{"NHOST_DISABLE_TLS"},
|
||||
},
|
||||
&cli.UintFlag{ //nolint:exhaustruct
|
||||
Name: flagPostgresPort,
|
||||
Usage: "Postgres port to listen on",
|
||||
Value: defaultPostgresPort,
|
||||
EnvVars: []string{"NHOST_POSTGRES_PORT"},
|
||||
},
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagApplySeeds,
|
||||
Usage: "Apply seeds. If the .nhost folder does not exist, seeds will be applied regardless of this flag",
|
||||
Value: false,
|
||||
EnvVars: []string{"NHOST_APPLY_SEEDS"},
|
||||
},
|
||||
&cli.UintFlag{ //nolint:exhaustruct
|
||||
Name: flagAuthPort,
|
||||
Usage: "If specified, expose auth on this port. Not recommended",
|
||||
Value: 0,
|
||||
},
|
||||
&cli.UintFlag{ //nolint:exhaustruct
|
||||
Name: flagStoragePort,
|
||||
Usage: "If specified, expose storage on this port. Not recommended",
|
||||
Value: 0,
|
||||
},
|
||||
&cli.UintFlag{ //nolint:exhaustruct
|
||||
Name: flagsFunctionsPort,
|
||||
Usage: "If specified, expose functions on this port. Not recommended",
|
||||
Value: 0,
|
||||
},
|
||||
&cli.UintFlag{ //nolint:exhaustruct
|
||||
Name: flagsHasuraPort,
|
||||
Usage: "If specified, expose hasura on this port. Not recommended",
|
||||
Value: 0,
|
||||
},
|
||||
&cli.UintFlag{ //nolint:exhaustruct
|
||||
Name: flagsHasuraConsolePort,
|
||||
Usage: "If specified, expose hasura console on this port. Not recommended",
|
||||
Value: 0,
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagDashboardVersion,
|
||||
Usage: "Dashboard version to use",
|
||||
Value: "nhost/dashboard:2.33.0",
|
||||
EnvVars: []string{"NHOST_DASHBOARD_VERSION"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagConfigserverImage,
|
||||
Hidden: true,
|
||||
Value: "",
|
||||
EnvVars: []string{"NHOST_CONFIGSERVER_IMAGE"},
|
||||
},
|
||||
&cli.StringSliceFlag{ //nolint:exhaustruct
|
||||
Name: flagRunService,
|
||||
Usage: "Run service to add to the development environment. Can be passed multiple times. Comma-separated values are also accepted. Format: /path/to/run-service.toml[:overlay_name]", //nolint:lll
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE"},
|
||||
},
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagDownOnError,
|
||||
Usage: "Skip confirmation",
|
||||
EnvVars: []string{"NHOST_YES"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagCACertificates,
|
||||
Usage: "Mounts and everrides path to CA certificates in the containers",
|
||||
EnvVars: []string{"NHOST_CA_CERTIFICATES"},
|
||||
},
|
||||
},
|
||||
Subcommands: []*cli.Command{
|
||||
CommandCloud(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandUp(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
// projname to be root directory
|
||||
|
||||
if !clienv.PathExists(ce.Path.NhostToml()) {
|
||||
return errors.New( //nolint:err113
|
||||
"no nhost project found, please run `nhost init` or `nhost config pull`",
|
||||
)
|
||||
}
|
||||
|
||||
if !clienv.PathExists(ce.Path.Secrets()) {
|
||||
return errors.New( //nolint:err113
|
||||
"no secrets found, please run `nhost init` or `nhost config pull`",
|
||||
)
|
||||
}
|
||||
|
||||
configserverImage := cCtx.String(flagConfigserverImage)
|
||||
if configserverImage == "" {
|
||||
configserverImage = "nhost/cli:" + cCtx.App.Version
|
||||
}
|
||||
|
||||
applySeeds := cCtx.Bool(flagApplySeeds) || !clienv.PathExists(ce.Path.DotNhostFolder())
|
||||
|
||||
return Up(
|
||||
cCtx.Context,
|
||||
ce,
|
||||
cCtx.App.Version,
|
||||
cCtx.Uint(flagHTTPPort),
|
||||
!cCtx.Bool(flagDisableTLS),
|
||||
cCtx.Uint(flagPostgresPort),
|
||||
applySeeds,
|
||||
dockercompose.ExposePorts{
|
||||
Auth: cCtx.Uint(flagAuthPort),
|
||||
Storage: cCtx.Uint(flagStoragePort),
|
||||
Graphql: cCtx.Uint(flagsHasuraPort),
|
||||
Console: cCtx.Uint(flagsHasuraConsolePort),
|
||||
Functions: cCtx.Uint(flagsFunctionsPort),
|
||||
},
|
||||
cCtx.String(flagDashboardVersion),
|
||||
configserverImage,
|
||||
cCtx.String(flagCACertificates),
|
||||
cCtx.StringSlice(flagRunService),
|
||||
cCtx.Bool(flagDownOnError),
|
||||
)
|
||||
}
|
||||
|
||||
func migrations(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
dc *dockercompose.DockerCompose,
|
||||
endpoint string,
|
||||
applySeeds bool,
|
||||
) error {
|
||||
if clienv.PathExists(filepath.Join(ce.Path.NhostFolder(), "migrations", "default")) {
|
||||
ce.Infoln("Applying migrations...")
|
||||
|
||||
if err := dc.ApplyMigrations(ctx, endpoint); err != nil {
|
||||
return fmt.Errorf("failed to apply migrations: %w", err)
|
||||
}
|
||||
} else {
|
||||
ce.Warnln("No migrations found, make sure this is intentional or it could lead to unexpected behavior")
|
||||
}
|
||||
|
||||
if clienv.PathExists(filepath.Join(ce.Path.NhostFolder(), "metadata", "version.yaml")) {
|
||||
ce.Infoln("Applying metadata...")
|
||||
|
||||
if err := dc.ApplyMetadata(ctx, endpoint); err != nil {
|
||||
return fmt.Errorf("failed to apply metadata: %w", err)
|
||||
}
|
||||
} else {
|
||||
ce.Warnln("No metadata found, make sure this is intentional or it could lead to unexpected behavior")
|
||||
}
|
||||
|
||||
if applySeeds {
|
||||
if clienv.PathExists(filepath.Join(ce.Path.NhostFolder(), "seeds", "default")) {
|
||||
ce.Infoln("Applying seeds...")
|
||||
|
||||
if err := dc.ApplySeeds(ctx, endpoint); err != nil {
|
||||
return fmt.Errorf("failed to apply seeds: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func restart(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
dc *dockercompose.DockerCompose,
|
||||
composeFile *dockercompose.ComposeFile,
|
||||
) error {
|
||||
ce.Infoln("Restarting services to reapply metadata if needed...")
|
||||
|
||||
args := []string{"restart"}
|
||||
|
||||
if _, ok := composeFile.Services["storage"]; ok {
|
||||
args = append(args, "storage")
|
||||
}
|
||||
|
||||
if _, ok := composeFile.Services["auth"]; ok {
|
||||
args = append(args, "auth")
|
||||
}
|
||||
|
||||
if _, ok := composeFile.Services["ai"]; ok {
|
||||
args = append(args, "ai")
|
||||
}
|
||||
|
||||
if _, ok := composeFile.Services["functions"]; ok {
|
||||
args = append(args, "functions")
|
||||
}
|
||||
|
||||
if err := dc.Wrapper(ctx, args...); err != nil {
|
||||
return fmt.Errorf("failed to restart services: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Verifying services are healthy...")
|
||||
|
||||
// this ensures that all services are healthy before returning
|
||||
if err := dc.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to wait services: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func reload(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
dc *dockercompose.DockerCompose,
|
||||
) error {
|
||||
ce.Infoln("Reapplying metadata...")
|
||||
|
||||
if err := dc.ReloadMetadata(ctx); err != nil {
|
||||
return fmt.Errorf("failed to reapply metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRunServiceConfigFlag(value string) (string, string, error) {
|
||||
parts := strings.Split(value, ":")
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
return parts[0], "", nil
|
||||
case 2: //nolint:mnd
|
||||
return parts[0], parts[1], nil
|
||||
default:
|
||||
return "", "", fmt.Errorf( //nolint:err113
|
||||
"invalid run service format, must be /path/to/config.toml:overlay_name, got %s",
|
||||
value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func processRunServices(
|
||||
ce *clienv.CliEnv,
|
||||
runServices []string,
|
||||
secrets model.Secrets,
|
||||
) ([]*dockercompose.RunService, error) {
|
||||
r := make([]*dockercompose.RunService, 0, len(runServices))
|
||||
for _, runService := range runServices {
|
||||
cfgPath, overlayName, err := parseRunServiceConfigFlag(runService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, err := run.Validate(ce, cfgPath, overlayName, secrets, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to validate run service %s: %w", cfgPath, err)
|
||||
}
|
||||
|
||||
r = append(r, &dockercompose.RunService{
|
||||
Path: cfgPath,
|
||||
Config: cfg,
|
||||
})
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func up( //nolint:funlen,cyclop
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
appVersion string,
|
||||
dc *dockercompose.DockerCompose,
|
||||
httpPort uint,
|
||||
useTLS bool,
|
||||
postgresPort uint,
|
||||
applySeeds bool,
|
||||
ports dockercompose.ExposePorts,
|
||||
dashboardVersion string,
|
||||
configserverImage string,
|
||||
caCertificatesPath string,
|
||||
runServices []string,
|
||||
) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
cancel()
|
||||
}()
|
||||
|
||||
var secrets model.Secrets
|
||||
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to parse secrets, make sure secret values are between quotes: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cfg, err := config.Validate(ce, "local", secrets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate config: %w", err)
|
||||
}
|
||||
|
||||
ctxWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second) //nolint:mnd
|
||||
defer cancel()
|
||||
|
||||
ce.Infoln("Checking versions...")
|
||||
|
||||
if err := software.CheckVersions(ctxWithTimeout, ce, cfg, appVersion); err != nil {
|
||||
ce.Warnln("Problem verifying recommended versions: %s", err.Error())
|
||||
}
|
||||
|
||||
runServicesCfg, err := processRunServices(ce, runServices, secrets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ce.Infoln("Setting up Nhost development environment...")
|
||||
|
||||
composeFile, err := dockercompose.ComposeFileFromConfig(
|
||||
cfg,
|
||||
ce.LocalSubdomain(),
|
||||
ce.ProjectName(),
|
||||
httpPort,
|
||||
useTLS,
|
||||
postgresPort,
|
||||
ce.Path.NhostFolder(),
|
||||
ce.Path.DotNhostFolder(),
|
||||
ce.Path.Root(),
|
||||
ports,
|
||||
ce.Branch(),
|
||||
dashboardVersion,
|
||||
configserverImage,
|
||||
clienv.PathExists(ce.Path.Functions()),
|
||||
caCertificatesPath,
|
||||
runServicesCfg...,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate docker-compose.yaml: %w", err)
|
||||
}
|
||||
|
||||
if err := dc.WriteComposeFile(composeFile); err != nil {
|
||||
return fmt.Errorf("failed to write docker-compose.yaml: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Starting Nhost development environment...")
|
||||
|
||||
if err = dc.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to start Nhost development environment: %w", err)
|
||||
}
|
||||
|
||||
if err := migrations(ctx, ce, dc, "http://graphql:8080", applySeeds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := restart(ctx, ce, dc, composeFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
docker := dockercompose.NewDocker()
|
||||
|
||||
ce.Infoln("Downloading metadata...")
|
||||
|
||||
if err := docker.HasuraWrapper(
|
||||
ctx,
|
||||
ce.LocalSubdomain(),
|
||||
ce.Path.NhostFolder(),
|
||||
*cfg.Hasura.Version,
|
||||
"metadata", "export",
|
||||
"--skip-update-check",
|
||||
"--log-level", "ERROR",
|
||||
"--endpoint", dockercompose.URL(ce.LocalSubdomain(), "hasura", httpPort, useTLS),
|
||||
"--admin-secret", cfg.Hasura.AdminSecret,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to create metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := reload(ctx, ce, dc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ce.Infoln("Nhost development environment started.")
|
||||
printInfo(ce.LocalSubdomain(), httpPort, postgresPort, useTLS, runServicesCfg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printInfo(
|
||||
subdomain string,
|
||||
httpPort, postgresPort uint,
|
||||
useTLS bool,
|
||||
runServices []*dockercompose.RunService,
|
||||
) {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) //nolint:mnd
|
||||
fmt.Fprintf(w, "URLs:\n")
|
||||
fmt.Fprintf(w,
|
||||
"- Postgres:\t\tpostgres://postgres:postgres@localhost:%d/local\n",
|
||||
postgresPort,
|
||||
)
|
||||
fmt.Fprintf(w, "- Hasura:\t\t%s\n", dockercompose.URL(
|
||||
subdomain, "hasura", httpPort, useTLS))
|
||||
fmt.Fprintf(w, "- GraphQL:\t\t%s\n", dockercompose.URL(
|
||||
subdomain, "graphql", httpPort, useTLS))
|
||||
fmt.Fprintf(w, "- Auth:\t\t%s\n", dockercompose.URL(
|
||||
subdomain, "auth", httpPort, useTLS))
|
||||
fmt.Fprintf(w, "- Storage:\t\t%s\n", dockercompose.URL(
|
||||
subdomain, "storage", httpPort, useTLS))
|
||||
fmt.Fprintf(w, "- Functions:\t\t%s\n", dockercompose.URL(
|
||||
subdomain, "functions", httpPort, useTLS))
|
||||
fmt.Fprintf(w, "- Dashboard:\t\t%s\n", dockercompose.URL(
|
||||
subdomain, "dashboard", httpPort, useTLS))
|
||||
fmt.Fprintf(w, "- Mailhog:\t\t%s\n", dockercompose.URL(
|
||||
subdomain, "mailhog", httpPort, useTLS))
|
||||
|
||||
for _, svc := range runServices {
|
||||
for _, port := range svc.Config.GetPorts() {
|
||||
if deptr(port.GetPublish()) {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"- run-%s:\t\tFrom laptop:\t%s://localhost:%d\n",
|
||||
svc.Config.Name,
|
||||
port.GetType(),
|
||||
port.GetPort(),
|
||||
)
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"\t\tFrom services:\t%s://run-%s:%d\n",
|
||||
port.GetType(),
|
||||
svc.Config.Name,
|
||||
port.GetPort(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n")
|
||||
fmt.Fprintf(w, "SDK Configuration:\n")
|
||||
fmt.Fprintf(w, " Subdomain:\t%s\n", subdomain)
|
||||
fmt.Fprintf(w, " Region:\tlocal\n")
|
||||
fmt.Fprintf(w, "")
|
||||
fmt.Fprintf(w, "Run `nhost up` to reload the development environment\n")
|
||||
fmt.Fprintf(w, "Run `nhost down` to stop the development environment\n")
|
||||
fmt.Fprintf(w, "Run `nhost logs` to watch the logs\n")
|
||||
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func upErr(
|
||||
ce *clienv.CliEnv,
|
||||
dc *dockercompose.DockerCompose,
|
||||
downOnError bool,
|
||||
err error,
|
||||
) error {
|
||||
ce.Warnln("%s", err.Error())
|
||||
|
||||
if !downOnError {
|
||||
ce.PromptMessage("Do you want to stop Nhost's development environment? [y/N] ")
|
||||
|
||||
resp, err := ce.PromptInput(false)
|
||||
if err != nil {
|
||||
ce.Warnln("failed to read input: %s", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp != "y" && resp != "Y" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ce.Infoln("Stopping Nhost development environment...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := dc.Stop(ctx, false); err != nil {
|
||||
ce.Warnln("failed to stop Nhost development environment: %s", err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func Up(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
appVersion string,
|
||||
httpPort uint,
|
||||
useTLS bool,
|
||||
postgresPort uint,
|
||||
applySeeds bool,
|
||||
ports dockercompose.ExposePorts,
|
||||
dashboardVersion string,
|
||||
configserverImage string,
|
||||
caCertificatesPath string,
|
||||
runServices []string,
|
||||
downOnError bool,
|
||||
) error {
|
||||
dc := dockercompose.New(ce.Path.WorkingDir(), ce.Path.DockerCompose(), ce.ProjectName())
|
||||
|
||||
if err := up(
|
||||
ctx,
|
||||
ce,
|
||||
appVersion,
|
||||
dc,
|
||||
httpPort,
|
||||
useTLS,
|
||||
postgresPort,
|
||||
applySeeds,
|
||||
ports,
|
||||
dashboardVersion,
|
||||
configserverImage,
|
||||
caCertificatesPath,
|
||||
runServices,
|
||||
); err != nil {
|
||||
return upErr(ce, dc, downOnError, err) //nolint:contextcheck
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
170
cli/cmd/dockercredentials/configure.go
Normal file
170
cli/cmd/dockercredentials/configure.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package dockercredentials
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagDockerConfig = "docker-config"
|
||||
flagNoInteractive = "no-interactive"
|
||||
)
|
||||
|
||||
const (
|
||||
credentialsPath = "/usr/local/bin/docker-credential-nhost-login" //nolint:gosec
|
||||
credentialsHelper = "nhost-login"
|
||||
)
|
||||
|
||||
func CommandConfigure() *cli.Command {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "/root"
|
||||
}
|
||||
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "configure",
|
||||
Aliases: []string{},
|
||||
Usage: "Install credentials helper and configure docker so it can authenticate with Nhost's registry",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagDockerConfig,
|
||||
Usage: "Path to docker config file",
|
||||
EnvVars: []string{"DOCKER_CONFIG"},
|
||||
Value: home + "/.docker/config.json",
|
||||
},
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagNoInteractive,
|
||||
Usage: "Do not prompt for confirmation",
|
||||
EnvVars: []string{"NO_INTERACTIVE"},
|
||||
Value: false,
|
||||
},
|
||||
},
|
||||
Action: actionConfigure,
|
||||
}
|
||||
}
|
||||
|
||||
const script = `#!/bin/sh
|
||||
%s docker-credentials $@
|
||||
`
|
||||
|
||||
func canSudo(ctx context.Context) bool {
|
||||
if err := exec.CommandContext(ctx, "sudo", "-n", "true").Run(); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func writeScript(ctx context.Context, ce *clienv.CliEnv) error {
|
||||
ce.Println("Installing credentials helper for docker in %s", credentialsPath)
|
||||
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get executable path: %w", err)
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(script, executable)
|
||||
|
||||
tmpfile, err := os.CreateTemp("", "nhost-docker-credentials")
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create temporary file: %w", err)
|
||||
}
|
||||
defer tmpfile.Close()
|
||||
|
||||
if _, err := tmpfile.WriteString(script); err != nil {
|
||||
return fmt.Errorf("could not write to temporary file: %w", err)
|
||||
}
|
||||
|
||||
if err := tmpfile.Chmod(0o755); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("could not chmod temporary file: %w", err)
|
||||
}
|
||||
|
||||
if !canSudo(ctx) {
|
||||
ce.Println("I need root privileges to install the file. Please, enter your password.")
|
||||
}
|
||||
|
||||
if err := exec.CommandContext( //nolint:gosec
|
||||
ctx, "sudo", "mv", tmpfile.Name(), credentialsPath,
|
||||
).Run(); err != nil {
|
||||
return fmt.Errorf("could not move temporary file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func configureDocker(dockerConfig string) error {
|
||||
f, err := os.OpenFile(dockerConfig, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o644) //nolint:mnd
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not open docker config file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := json.NewDecoder(f).Decode(&config); err != nil {
|
||||
config = make(map[string]interface{})
|
||||
}
|
||||
|
||||
credHelpers, ok := config["credHelpers"].(map[string]interface{})
|
||||
if !ok {
|
||||
credHelpers = make(map[string]interface{})
|
||||
}
|
||||
|
||||
credHelpers["registry.ap-south-1.nhost.run"] = credentialsHelper
|
||||
credHelpers["registry.ap-southeast-1.nhost.run"] = credentialsHelper
|
||||
credHelpers["registry.eu-central-1.nhost.run"] = credentialsHelper
|
||||
credHelpers["registry.eu-west-2.nhost.run"] = credentialsHelper
|
||||
credHelpers["registry.us-east-1.nhost.run"] = credentialsHelper
|
||||
credHelpers["registry.sa-east-1.nhost.run"] = credentialsHelper
|
||||
credHelpers["registry.us-west-2.nhost.run"] = credentialsHelper
|
||||
|
||||
config["credHelpers"] = credHelpers
|
||||
|
||||
if err := f.Truncate(0); err != nil {
|
||||
return fmt.Errorf("could not truncate docker config file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := f.Seek(0, 0); err != nil {
|
||||
return fmt.Errorf("could not seek docker config file: %w", err)
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(f).Encode(config); err != nil {
|
||||
return fmt.Errorf("could not encode docker config file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func actionConfigure(c *cli.Context) error {
|
||||
ce := clienv.FromCLI(c)
|
||||
|
||||
if err := writeScript(c.Context, ce); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Bool(flagNoInteractive) {
|
||||
return configureDocker(c.String(flagDockerConfig))
|
||||
}
|
||||
|
||||
//nolint:lll
|
||||
ce.PromptMessage(
|
||||
"I am about to configure docker to authenticate with Nhost's registry. This will modify your docker config file on %s. Should I continue? [y/N] ",
|
||||
c.String(flagDockerConfig),
|
||||
)
|
||||
|
||||
v, err := ce.PromptInput(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not read input: %w", err)
|
||||
}
|
||||
|
||||
if v == "y" || v == "Y" {
|
||||
return configureDocker(c.String(flagDockerConfig))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
17
cli/cmd/dockercredentials/docker.go
Normal file
17
cli/cmd/dockercredentials/docker.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package dockercredentials
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func Command() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "docker-credentials",
|
||||
Aliases: []string{},
|
||||
Usage: "Perform docker-credentials operations",
|
||||
Subcommands: []*cli.Command{
|
||||
CommandGet(),
|
||||
CommandErase(),
|
||||
CommandStore(),
|
||||
CommandConfigure(),
|
||||
},
|
||||
}
|
||||
}
|
||||
20
cli/cmd/dockercredentials/erase.go
Normal file
20
cli/cmd/dockercredentials/erase.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package dockercredentials
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandErase() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "erase",
|
||||
Aliases: []string{},
|
||||
Hidden: true,
|
||||
Usage: "This action doesn't do anything",
|
||||
Action: actionErase,
|
||||
}
|
||||
}
|
||||
|
||||
func actionErase(c *cli.Context) error {
|
||||
_, _ = c.App.Writer.Write([]byte("Please, use the nhost CLI to logout\n"))
|
||||
return nil
|
||||
}
|
||||
99
cli/cmd/dockercredentials/get.go
Normal file
99
cli/cmd/dockercredentials/get.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package dockercredentials
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagAuthURL = "auth-url"
|
||||
flagGraphqlURL = "graphql-url"
|
||||
)
|
||||
|
||||
func CommandGet() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "get",
|
||||
Aliases: []string{},
|
||||
Usage: "Get credentials for the logged in user",
|
||||
Hidden: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagAuthURL,
|
||||
Usage: "Nhost auth URL",
|
||||
EnvVars: []string{"NHOST_CLI_AUTH_URL"},
|
||||
Value: "https://otsispdzcwxyqzbfntmj.auth.eu-central-1.nhost.run/v1",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagGraphqlURL,
|
||||
Usage: "Nhost GraphQL URL",
|
||||
EnvVars: []string{"NHOST_CLI_GRAPHQL_URL"},
|
||||
Value: "https://otsispdzcwxyqzbfntmj.graphql.eu-central-1.nhost.run/v1",
|
||||
Hidden: true,
|
||||
},
|
||||
},
|
||||
Action: actionGet,
|
||||
}
|
||||
}
|
||||
|
||||
func getToken(ctx context.Context, authURL, graphqlURL string) (string, error) {
|
||||
ce := clienv.New(
|
||||
os.Stdout,
|
||||
os.Stderr,
|
||||
&clienv.PathStructure{},
|
||||
authURL,
|
||||
graphqlURL,
|
||||
"unneeded",
|
||||
"unneeded",
|
||||
"unneeded",
|
||||
)
|
||||
|
||||
session, err := ce.LoadSession(ctx)
|
||||
if err != nil {
|
||||
return "", err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
return session.Session.AccessToken, nil
|
||||
}
|
||||
|
||||
//nolint:tagliatelle
|
||||
type response struct {
|
||||
ServerURL string `json:"ServerURL"`
|
||||
Username string `json:"Username"`
|
||||
Secret string `json:"Secret"`
|
||||
}
|
||||
|
||||
func actionGet(c *cli.Context) error {
|
||||
scanner := bufio.NewScanner(c.App.Reader)
|
||||
|
||||
var input string
|
||||
for scanner.Scan() {
|
||||
input += scanner.Text()
|
||||
}
|
||||
|
||||
token, err := getToken(c.Context, c.String(flagAuthURL), c.String(flagGraphqlURL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, err := json.Marshal(response{
|
||||
ServerURL: input,
|
||||
Username: "nhost",
|
||||
Secret: token,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal response: %w", err)
|
||||
}
|
||||
|
||||
if _, err = c.App.Writer.Write(b); err != nil {
|
||||
return fmt.Errorf("failed to write response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
20
cli/cmd/dockercredentials/store.go
Normal file
20
cli/cmd/dockercredentials/store.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package dockercredentials
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandStore() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "store",
|
||||
Aliases: []string{},
|
||||
Hidden: true,
|
||||
Usage: "This action doesn't do anything",
|
||||
Action: actionStore,
|
||||
}
|
||||
}
|
||||
|
||||
func actionStore(c *cli.Context) error {
|
||||
_, _ = c.App.Writer.Write([]byte("Please, use the nhost CLI to login\n"))
|
||||
return nil
|
||||
}
|
||||
253
cli/cmd/project/init.go
Normal file
253
cli/cmd/project/init.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/hashicorp/go-getter"
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/cmd/config"
|
||||
"github.com/nhost/nhost/cli/dockercompose"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
flagRemote = "remote"
|
||||
)
|
||||
|
||||
//go:embed templates/init/*
|
||||
var embeddedFS embed.FS
|
||||
|
||||
func writeFiles(ps *clienv.PathStructure, root, relPath string) error {
|
||||
dirEntries, err := embeddedFS.ReadDir(filepath.Join(root, relPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read dir: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range dirEntries {
|
||||
if entry.IsDir() {
|
||||
return writeFiles(ps, root, filepath.Join(relPath, entry.Name()))
|
||||
}
|
||||
|
||||
src := filepath.Join(root, relPath, entry.Name())
|
||||
|
||||
fileData, err := fs.ReadFile(embeddedFS, src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file %s: %w", src, err)
|
||||
}
|
||||
|
||||
dst := filepath.Join(ps.Root(), relPath, entry.Name())
|
||||
|
||||
f, err := os.OpenFile(
|
||||
filepath.Join(ps.Root(), dst),
|
||||
os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600, //nolint:mnd
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file %s: %w", dst, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(fileData); err != nil {
|
||||
return fmt.Errorf("failed to write file %s: %w", dst, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const hasuraMetadataVersion = 3
|
||||
|
||||
func CommandInit() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "init",
|
||||
Aliases: []string{},
|
||||
Usage: "Initialize a new Nhost project",
|
||||
Action: commandInit,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagRemote,
|
||||
Usage: "Initialize pulling configuration, migrations and metadata from the linked project",
|
||||
Value: false,
|
||||
EnvVars: []string{"NHOST_REMOTE"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandInit(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
if clienv.PathExists(ce.Path.NhostFolder()) {
|
||||
return errors.New("nhost folder already exists") //nolint:err113
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(ce.Path.NhostFolder(), 0o755); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to create nhost folder: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Initializing Nhost project")
|
||||
|
||||
if err := config.InitConfigAndSecrets(ce); err != nil {
|
||||
return fmt.Errorf("failed to initialize configuration: %w", err)
|
||||
}
|
||||
|
||||
if cCtx.Bool(flagRemote) {
|
||||
if err := InitRemote(cCtx.Context, ce); err != nil {
|
||||
return fmt.Errorf("failed to initialize remote project: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := initInit(cCtx.Context, ce.Path); err != nil {
|
||||
return fmt.Errorf("failed to initialize project: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ce.Infoln("Successfully initialized Nhost project, run `nhost up` to start development")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initInit(
|
||||
ctx context.Context, ps *clienv.PathStructure,
|
||||
) error {
|
||||
hasuraConf := map[string]any{"version": hasuraMetadataVersion}
|
||||
if err := clienv.MarshalFile(hasuraConf, ps.HasuraConfig(), yaml.Marshal); err != nil {
|
||||
return fmt.Errorf("failed to save hasura config: %w", err)
|
||||
}
|
||||
|
||||
if err := initFolders(ps); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeFiles(ps, "templates/init", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
getclient := &getter.Client{ //nolint:exhaustruct
|
||||
Ctx: ctx,
|
||||
Src: "github.com/nhost/hasura-auth/email-templates",
|
||||
Dst: "nhost/emails",
|
||||
Mode: getter.ClientModeAny,
|
||||
Detectors: []getter.Detector{
|
||||
&getter.GitHubDetector{},
|
||||
},
|
||||
}
|
||||
|
||||
if err := getclient.Get(); err != nil {
|
||||
return fmt.Errorf("failed to download email templates: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initFolders(ps *clienv.PathStructure) error {
|
||||
folders := []string{
|
||||
ps.DotNhostFolder(),
|
||||
filepath.Join(ps.Root(), "functions"),
|
||||
filepath.Join(ps.NhostFolder(), "migrations", "default"),
|
||||
filepath.Join(ps.NhostFolder(), "metadata"),
|
||||
filepath.Join(ps.NhostFolder(), "seeds"),
|
||||
filepath.Join(ps.NhostFolder(), "emails"),
|
||||
}
|
||||
for _, f := range folders {
|
||||
if err := os.MkdirAll(f, 0o755); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to create folder %s: %w", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitRemote(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
) error {
|
||||
proj, err := ce.GetAppInfo(ctx, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Pull(ctx, ce, proj, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull config: %w", err)
|
||||
}
|
||||
|
||||
if err := initInit(ctx, ce.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cl, err := ce.GetNhostClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
hasuraAdminSecret, err := cl.GetHasuraAdminSecret(ctx, proj.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get hasura admin secret: %w", err)
|
||||
}
|
||||
|
||||
hasuraEndpoint := fmt.Sprintf(
|
||||
"https://%s.hasura.%s.nhost.run", proj.Subdomain, proj.Region.Name,
|
||||
)
|
||||
|
||||
if err := deploy(
|
||||
ctx, ce, cfg, hasuraEndpoint, hasuraAdminSecret.App.Config.Hasura.AdminSecret,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to deploy: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Project initialized successfully!")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func deploy(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
cfg *model.ConfigConfig,
|
||||
hasuraEndpoint string,
|
||||
hasuraAdminSecret string,
|
||||
) error {
|
||||
docker := dockercompose.NewDocker()
|
||||
|
||||
ce.Infoln("Creating postgres migration")
|
||||
|
||||
if err := docker.HasuraWrapper(
|
||||
ctx,
|
||||
ce.LocalSubdomain(),
|
||||
ce.Path.NhostFolder(),
|
||||
*cfg.Hasura.Version,
|
||||
"migrate", "create", "init", "--from-server", "--schema", "public",
|
||||
"--database-name", "default",
|
||||
"--skip-update-check",
|
||||
"--log-level", "ERROR",
|
||||
"--endpoint", hasuraEndpoint,
|
||||
"--admin-secret", hasuraAdminSecret,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to create postgres migration: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Downloading metadata...")
|
||||
|
||||
if err := docker.HasuraWrapper(
|
||||
ctx,
|
||||
ce.LocalSubdomain(),
|
||||
ce.Path.NhostFolder(),
|
||||
*cfg.Hasura.Version,
|
||||
"metadata", "export",
|
||||
"--skip-update-check",
|
||||
"--log-level", "ERROR",
|
||||
"--endpoint", hasuraEndpoint,
|
||||
"--admin-secret", hasuraAdminSecret,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to create metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
31
cli/cmd/project/link.go
Normal file
31
cli/cmd/project/link.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandLink() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "link",
|
||||
Aliases: []string{},
|
||||
Usage: "Link local app to a remote one",
|
||||
Action: commandLink,
|
||||
Flags: []cli.Flag{},
|
||||
}
|
||||
}
|
||||
|
||||
func commandLink(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
if err := os.MkdirAll(ce.Path.DotNhostFolder(), 0o755); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to create .nhost folder: %w", err)
|
||||
}
|
||||
|
||||
_, err := ce.Link(cCtx.Context)
|
||||
|
||||
return err //nolint:wrapcheck
|
||||
}
|
||||
38
cli/cmd/project/list.go
Normal file
38
cli/cmd/project/list.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandList() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "list",
|
||||
Aliases: []string{},
|
||||
Usage: "List remote apps",
|
||||
Action: commandList,
|
||||
Flags: []cli.Flag{},
|
||||
}
|
||||
}
|
||||
|
||||
func commandList(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
return List(cCtx.Context, ce)
|
||||
}
|
||||
|
||||
func List(ctx context.Context, ce *clienv.CliEnv) error {
|
||||
cl, err := ce.GetNhostClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
orgs, err := cl.GetOrganizationsAndWorkspacesApps(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get workspaces: %w", err)
|
||||
}
|
||||
|
||||
return clienv.Printlist(ce, orgs) //nolint:wrapcheck
|
||||
}
|
||||
1
cli/cmd/project/project.go
Normal file
1
cli/cmd/project/project.go
Normal file
@@ -0,0 +1 @@
|
||||
package project
|
||||
2
cli/cmd/project/templates/init/.gitignore
vendored
Normal file
2
cli/cmd/project/templates/init/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.nhost
|
||||
.secrets
|
||||
14
cli/cmd/project/templates/init/functions/package-lock.json
generated
Normal file
14
cli/cmd/project/templates/init/functions/package-lock.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
cli/cmd/project/templates/init/functions/package.json
Normal file
13
cli/cmd/project/templates/init/functions/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"devDependencies": {},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
95
cli/cmd/run/config_deploy.go
Normal file
95
cli/cmd/run/config_deploy.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/nhostclient/graphql"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandConfigDeploy() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "config-deploy",
|
||||
Aliases: []string{},
|
||||
Usage: "Deploy service configuration",
|
||||
Action: commandConfigDeploy,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagConfig,
|
||||
Aliases: []string{},
|
||||
Usage: "Service configuration file",
|
||||
Value: "nhost-run-service.toml",
|
||||
Required: true,
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagServiceID,
|
||||
Usage: "Service ID to update. Applies overlay of the same name",
|
||||
Required: true,
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_ID"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func transform[T, V any](t *T) (*V, error) {
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal: %w", err)
|
||||
}
|
||||
|
||||
var v V
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal: %w", err)
|
||||
}
|
||||
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func commandConfigDeploy(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
cl, err := ce.GetNhostClient(cCtx.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
secrets, appID, err := getRemoteSecrets(cCtx.Context, cl, cCtx.String(flagServiceID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := Validate(
|
||||
ce,
|
||||
cCtx.String(flagConfig),
|
||||
cCtx.String(flagServiceID),
|
||||
secrets,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
replaceConfig, err := transform[model.ConfigRunServiceConfig, graphql.ConfigRunServiceConfigInsertInput](
|
||||
cfg,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to transform configuration into replace input: %w", err)
|
||||
}
|
||||
|
||||
if _, err := cl.ReplaceRunServiceConfig(
|
||||
cCtx.Context,
|
||||
appID,
|
||||
cCtx.String(flagServiceID),
|
||||
*replaceConfig,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to replace service config: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Service configuration replaced")
|
||||
|
||||
return nil
|
||||
}
|
||||
95
cli/cmd/run/config_edit.go
Normal file
95
cli/cmd/run/config_edit.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/cmd/config"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const flagEditor = "editor"
|
||||
|
||||
func CommandConfigEdit() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "config-edit",
|
||||
Aliases: []string{},
|
||||
Usage: "Edit service configuration",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagConfig,
|
||||
Aliases: []string{},
|
||||
Usage: "Service configuration file",
|
||||
Value: "nhost-run-service.toml",
|
||||
Required: true,
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagEditor,
|
||||
Usage: "Editor to use",
|
||||
Value: "vim",
|
||||
EnvVars: []string{"EDITOR"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagOverlayName,
|
||||
Usage: "If specified, apply this overlay",
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_ID", "NHOST_SERVICE_OVERLAY_NAME"},
|
||||
},
|
||||
},
|
||||
Action: commandConfigEdit,
|
||||
}
|
||||
}
|
||||
|
||||
func commandConfigEdit(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
overlayName := cCtx.String(flagOverlayName)
|
||||
if overlayName == "" {
|
||||
if err := config.EditFile(
|
||||
cCtx.Context, cCtx.String(flagEditor), cCtx.String(flagConfig),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to edit config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(ce.Path.RunServiceOverlaysFolder(
|
||||
cCtx.String(flagConfig),
|
||||
), 0o755); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to create json patches directory: %w", err)
|
||||
}
|
||||
|
||||
tmpdir, err := os.MkdirTemp(os.TempDir(), "nhost-jsonpatch")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
tmpfileName := filepath.Join(tmpdir, "nhost.toml")
|
||||
|
||||
if err := config.CopyConfig[model.ConfigRunServiceConfig](
|
||||
cCtx.String(flagConfig),
|
||||
tmpfileName,
|
||||
ce.Path.RunServiceOverlay(cCtx.String(flagConfig), overlayName),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to copy config: %w", err)
|
||||
}
|
||||
|
||||
if err := config.EditFile(cCtx.Context, cCtx.String(flagEditor), tmpfileName); err != nil {
|
||||
return fmt.Errorf("failed to edit config: %w", err)
|
||||
}
|
||||
|
||||
if err := config.GenerateJSONPatch(
|
||||
cCtx.String(flagConfig),
|
||||
tmpfileName,
|
||||
ce.Path.RunServiceOverlay(cCtx.String(flagConfig), overlayName),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to generate json patch: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
52
cli/cmd/run/config_edit_image.go
Normal file
52
cli/cmd/run/config_edit_image.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const flagImage = "image"
|
||||
|
||||
func CommandConfigEditImage() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "config-edit-image",
|
||||
Aliases: []string{},
|
||||
Usage: "Edits configuration file and sets the image",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagConfig,
|
||||
Aliases: []string{},
|
||||
Usage: "Service configuration file",
|
||||
Value: "nhost-run-service.toml",
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagImage,
|
||||
Aliases: []string{},
|
||||
Usage: "Image to use",
|
||||
Required: true,
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_IMAGE"},
|
||||
},
|
||||
},
|
||||
Action: commandConfigEditImage,
|
||||
}
|
||||
}
|
||||
|
||||
func commandConfigEditImage(cCtx *cli.Context) error {
|
||||
var cfg model.ConfigRunServiceConfig
|
||||
if err := clienv.UnmarshalFile(cCtx.String(flagConfig), &cfg, toml.Unmarshal); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
cfg.Image.Image = cCtx.String(flagImage)
|
||||
|
||||
if err := clienv.MarshalFile(cfg, cCtx.String(flagConfig), toml.Marshal); err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
107
cli/cmd/run/config_example.go
Normal file
107
cli/cmd/run/config_example.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/be/services/mimir/schema"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func CommandConfigExample() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "config-example",
|
||||
Aliases: []string{},
|
||||
Usage: "Shows an example config file",
|
||||
Action: commandConfigExample,
|
||||
Flags: []cli.Flag{},
|
||||
}
|
||||
}
|
||||
|
||||
func commandConfigExample(cCtx *cli.Context) error { //nolint:funlen
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
//nolint:mnd
|
||||
cfg := &model.ConfigRunServiceConfig{
|
||||
Name: "my-run-service",
|
||||
Image: &model.ConfigRunServiceImage{
|
||||
Image: "docker.io/org/img:latest",
|
||||
PullCredentials: ptr(
|
||||
`{"https://myregistry.com/v1": {"username": "myuser", "password": "mypassword"}}`,
|
||||
),
|
||||
},
|
||||
Command: []string{
|
||||
"start",
|
||||
},
|
||||
Environment: []*model.ConfigEnvironmentVariable{
|
||||
{
|
||||
Name: "ENV_VAR1",
|
||||
Value: "value1",
|
||||
},
|
||||
{
|
||||
Name: "ENV_VAR2",
|
||||
Value: "value2",
|
||||
},
|
||||
},
|
||||
Ports: []*model.ConfigRunServicePort{
|
||||
{
|
||||
Port: 8080,
|
||||
Type: "http",
|
||||
Publish: ptr(true),
|
||||
Ingresses: []*model.ConfigIngress{
|
||||
{
|
||||
Fqdn: []string{"my-run-service.acme.com"},
|
||||
Tls: &model.ConfigIngressTls{
|
||||
ClientCA: ptr("---BEGIN CERTIFICATE---\n...\n---END CERTIFICATE---"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Resources: &model.ConfigRunServiceResources{
|
||||
Compute: &model.ConfigComputeResources{
|
||||
Cpu: 125,
|
||||
Memory: 256,
|
||||
},
|
||||
Storage: []*model.ConfigRunServiceResourcesStorage{
|
||||
{
|
||||
Name: "my-storage",
|
||||
Capacity: 1,
|
||||
Path: "/var/lib/my-storage",
|
||||
},
|
||||
},
|
||||
Replicas: 1,
|
||||
Autoscaler: nil,
|
||||
},
|
||||
HealthCheck: &model.ConfigHealthCheck{
|
||||
Port: 8080,
|
||||
InitialDelaySeconds: ptr(10),
|
||||
ProbePeriodSeconds: ptr(20),
|
||||
},
|
||||
}
|
||||
|
||||
sch, err := schema.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
cfg, err = sch.FillRunServiceConfig(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate config: %w", err)
|
||||
}
|
||||
|
||||
b, err := toml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
ce.Println("%s", b)
|
||||
|
||||
return nil
|
||||
}
|
||||
72
cli/cmd/run/config_pull.go
Normal file
72
cli/cmd/run/config_pull.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const flagServiceID = "service-id"
|
||||
|
||||
func CommandConfigPull() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "config-pull",
|
||||
Aliases: []string{},
|
||||
Usage: "Download service configuration",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagConfig,
|
||||
Aliases: []string{},
|
||||
Usage: "Service configuration file",
|
||||
Value: "nhost-run-service.toml",
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagServiceID,
|
||||
Usage: "Service ID to update",
|
||||
Required: true,
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_ID"},
|
||||
},
|
||||
},
|
||||
Action: commandConfigPull,
|
||||
}
|
||||
}
|
||||
|
||||
func commandConfigPull(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
cl, err := ce.GetNhostClient(cCtx.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
appID, err := getAppIDFromServiceID(cCtx.Context, cl, cCtx.String(flagServiceID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := cl.GetRunServiceConfigRawJSON(
|
||||
cCtx.Context,
|
||||
appID,
|
||||
cCtx.String(flagServiceID),
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get service config: %w", err)
|
||||
}
|
||||
|
||||
var v model.ConfigRunServiceConfig
|
||||
if err := json.Unmarshal([]byte(resp.RunServiceConfigRawJSON), &v); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := clienv.MarshalFile(v, cCtx.String(flagConfig), toml.Marshal); err != nil {
|
||||
return fmt.Errorf("failed to save config to file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
67
cli/cmd/run/config_show.go
Normal file
67
cli/cmd/run/config_show.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandConfigShow() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "config-show",
|
||||
Aliases: []string{},
|
||||
Usage: "Shows Run service configuration after resolving secrets",
|
||||
Description: "Note that this command will always use the local secrets, even if you specify subdomain",
|
||||
Action: commandConfigShow,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagConfig,
|
||||
Aliases: []string{},
|
||||
Usage: "Service configuration file",
|
||||
Value: "nhost-run-service.toml",
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagOverlayName,
|
||||
Usage: "If specified, apply this overlay",
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_ID", "NHOST_SERVICE_OVERLAY_NAME"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandConfigShow(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
var secrets model.Secrets
|
||||
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to parse secrets, make sure secret values are between quotes: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cfg, err := Validate(
|
||||
ce,
|
||||
cCtx.String(flagConfig),
|
||||
cCtx.String(flagOverlayName),
|
||||
secrets,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, err := toml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling config: %w", err)
|
||||
}
|
||||
|
||||
ce.Println("%s", b)
|
||||
|
||||
return nil
|
||||
}
|
||||
216
cli/cmd/run/config_validate.go
Normal file
216
cli/cmd/run/config_validate.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/be/services/mimir/schema"
|
||||
"github.com/nhost/be/services/mimir/schema/appconfig"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/cmd/config"
|
||||
"github.com/nhost/nhost/cli/nhostclient"
|
||||
"github.com/nhost/nhost/cli/nhostclient/graphql"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagConfig = "config"
|
||||
flagOverlayName = "overlay-name"
|
||||
)
|
||||
|
||||
func CommandConfigValidate() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "config-validate",
|
||||
Aliases: []string{},
|
||||
Usage: "Validates service configuration after resolving secrets",
|
||||
Action: commandConfigValidate,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagConfig,
|
||||
Aliases: []string{},
|
||||
Usage: "Service configuration file",
|
||||
Value: "nhost-run-service.toml",
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagOverlayName,
|
||||
Usage: "If specified, apply this overlay",
|
||||
EnvVars: []string{"NHOST_SERVICE_OVERLAY_NAME"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagServiceID,
|
||||
Usage: "If specified, apply this overlay and remote secrets for this service",
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_ID"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func respToSecrets(env []*graphql.GetSecrets_AppSecrets) model.Secrets {
|
||||
secrets := make(model.Secrets, len(env))
|
||||
for i, s := range env {
|
||||
secrets[i] = &model.ConfigEnvironmentVariable{
|
||||
Name: s.Name,
|
||||
Value: s.Value,
|
||||
}
|
||||
}
|
||||
|
||||
return secrets
|
||||
}
|
||||
|
||||
func loadConfig(
|
||||
path string,
|
||||
) (*model.ConfigRunServiceConfig, error) {
|
||||
cfg := &model.ConfigRunServiceConfig{} //nolint:exhaustruct
|
||||
|
||||
r, err := os.Open(path)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
decoder := toml.NewDecoder(r)
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(cfg); err != nil {
|
||||
return cfg, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getAppIDFromServiceID(
|
||||
ctx context.Context,
|
||||
cl *nhostclient.Client,
|
||||
serviceID string,
|
||||
) (string, error) {
|
||||
resp, err := cl.GetRunServiceInfo(
|
||||
ctx,
|
||||
serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get app info from service id: %w", err)
|
||||
}
|
||||
|
||||
return resp.GetRunService().GetAppID(), nil
|
||||
}
|
||||
|
||||
func Validate(
|
||||
ce *clienv.CliEnv,
|
||||
configPath string,
|
||||
overlayName string,
|
||||
secrets model.Secrets,
|
||||
testSecretsOnly bool,
|
||||
) (*model.ConfigRunServiceConfig, error) {
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if clienv.PathExists(ce.Path.RunServiceOverlay(configPath, overlayName)) {
|
||||
cfg, err = config.ApplyJSONPatches(*cfg, ce.Path.RunServiceOverlay(configPath, overlayName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to apply json patches: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
schema, err := schema.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
cfgSecretsResolved, err := appconfig.SecretsResolver(cfg, secrets, schema.FillRunServiceConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to validate config: %w", err)
|
||||
}
|
||||
|
||||
if !testSecretsOnly {
|
||||
cfg = cfgSecretsResolved
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getRemoteSecrets(
|
||||
ctx context.Context,
|
||||
cl *nhostclient.Client,
|
||||
serviceID string,
|
||||
) (model.Secrets, string, error) {
|
||||
appID, err := getAppIDFromServiceID(ctx, cl, serviceID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
secretsResp, err := cl.GetSecrets(
|
||||
ctx,
|
||||
appID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get secrets: %w", err)
|
||||
}
|
||||
|
||||
return respToSecrets(secretsResp.GetAppSecrets()), appID, nil
|
||||
}
|
||||
|
||||
func commandConfigValidate(cCtx *cli.Context) error {
|
||||
var (
|
||||
overlayName string
|
||||
serviceID string
|
||||
)
|
||||
|
||||
switch {
|
||||
case cCtx.String(flagServiceID) != "" && cCtx.String(flagOverlayName) != "":
|
||||
return errors.New("cannot specify both service id and overlay name") //nolint:err113
|
||||
case cCtx.String(flagServiceID) != "":
|
||||
serviceID = cCtx.String(flagServiceID)
|
||||
overlayName = serviceID
|
||||
case cCtx.String(flagOverlayName) != "":
|
||||
overlayName = cCtx.String(flagOverlayName)
|
||||
}
|
||||
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
var secrets model.Secrets
|
||||
|
||||
ce.Infoln("Getting secrets...")
|
||||
|
||||
if serviceID != "" {
|
||||
cl, err := ce.GetNhostClient(cCtx.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
secrets, _, err = getRemoteSecrets(cCtx.Context, cl, serviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to parse secrets, make sure secret values are between quotes: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ce.Infoln("Verifying configuration...")
|
||||
|
||||
if _, err := Validate(
|
||||
ce,
|
||||
cCtx.String(flagConfig),
|
||||
overlayName,
|
||||
secrets,
|
||||
false,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ce.Infoln("Configuration is valid!")
|
||||
|
||||
return nil
|
||||
}
|
||||
84
cli/cmd/run/dev.go
Normal file
84
cli/cmd/run/dev.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagDevPrependExport = "prepend-export"
|
||||
)
|
||||
|
||||
const dotenvEscapeRegex = `[\\\"!\$]`
|
||||
|
||||
func CommandEnv() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "env",
|
||||
Aliases: []string{},
|
||||
Usage: "Outputs environment variables. Useful to generate .env files",
|
||||
Action: commandConfigDev,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagConfig,
|
||||
Aliases: []string{},
|
||||
Usage: "Service configuration file",
|
||||
Value: "nhost-run-service.toml",
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_CONFIG"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagOverlayName,
|
||||
Usage: "If specified, apply this overlay",
|
||||
EnvVars: []string{"NHOST_RUN_SERVICE_ID", "NHOST_SERVICE_OVERLAY_NAME"},
|
||||
},
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: flagDevPrependExport,
|
||||
Usage: "Prepend 'export' to each line",
|
||||
EnvVars: []string{"NHOST_RuN_SERVICE_ENV_PREPEND_EXPORT"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func escape(s string) string {
|
||||
re := regexp.MustCompile(dotenvEscapeRegex)
|
||||
return re.ReplaceAllString(s, "\\$0")
|
||||
}
|
||||
|
||||
func commandConfigDev(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
var secrets model.Secrets
|
||||
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to parse secrets, make sure secret values are between quotes: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cfg, err := Validate(
|
||||
ce,
|
||||
cCtx.String(flagConfig),
|
||||
cCtx.String(flagOverlayName),
|
||||
secrets,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, v := range cfg.GetEnvironment() {
|
||||
value := escape(v.Value)
|
||||
if cCtx.Bool(flagDevPrependExport) {
|
||||
ce.Println("export %s=\"%s\"", v.Name, value)
|
||||
} else {
|
||||
ce.Println("%s=\"%s\"", v.Name, value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
27
cli/cmd/run/dev_test.go
Normal file
27
cli/cmd/run/dev_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package run //nolint:testpackage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
s string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
s: `#asdasd;l;kq23\\n40-0as9d"$\`,
|
||||
want: `#asdasd;l;kq23\\\\n40-0as9d\"\$\\`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.s, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := escape(tc.s); got != tc.want {
|
||||
t.Errorf("escape() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
21
cli/cmd/run/run.go
Normal file
21
cli/cmd/run/run.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package run
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func Command() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "run",
|
||||
Aliases: []string{},
|
||||
Usage: "Perform operations on Nhost Run",
|
||||
Subcommands: []*cli.Command{
|
||||
CommandConfigShow(),
|
||||
CommandConfigDeploy(),
|
||||
CommandConfigEdit(),
|
||||
CommandConfigEditImage(),
|
||||
CommandConfigPull(),
|
||||
CommandConfigValidate(),
|
||||
CommandConfigExample(),
|
||||
CommandEnv(),
|
||||
},
|
||||
}
|
||||
}
|
||||
51
cli/cmd/secrets/create.go
Normal file
51
cli/cmd/secrets/create.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package secrets //nolint:dupl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandCreate() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "create",
|
||||
ArgsUsage: "NAME VALUE",
|
||||
Aliases: []string{},
|
||||
Usage: "Create secret in the cloud environment",
|
||||
Action: commandCreate,
|
||||
Flags: commonFlags(),
|
||||
}
|
||||
}
|
||||
|
||||
func commandCreate(cCtx *cli.Context) error {
|
||||
if cCtx.NArg() != 2 { //nolint:mnd
|
||||
return errors.New("invalid number of arguments") //nolint:err113
|
||||
}
|
||||
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
cl, err := ce.GetNhostClient(cCtx.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
if _, err := cl.CreateSecret(
|
||||
cCtx.Context,
|
||||
proj.ID,
|
||||
cCtx.Args().Get(0),
|
||||
cCtx.Args().Get(1),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to create secret: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Secret created successfully!")
|
||||
|
||||
return nil
|
||||
}
|
||||
50
cli/cmd/secrets/delete.go
Normal file
50
cli/cmd/secrets/delete.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandDelete() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "delete",
|
||||
ArgsUsage: "NAME",
|
||||
Aliases: []string{},
|
||||
Usage: "Delete secret in the cloud environment",
|
||||
Action: commandDelete,
|
||||
Flags: commonFlags(),
|
||||
}
|
||||
}
|
||||
|
||||
func commandDelete(cCtx *cli.Context) error {
|
||||
if cCtx.NArg() != 1 {
|
||||
return errors.New("invalid number of arguments") //nolint:err113
|
||||
}
|
||||
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
cl, err := ce.GetNhostClient(cCtx.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
if _, err := cl.DeleteSecret(
|
||||
cCtx.Context,
|
||||
proj.ID,
|
||||
cCtx.Args().Get(0),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to delete secret: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Secret deleted successfully!")
|
||||
|
||||
return nil
|
||||
}
|
||||
46
cli/cmd/secrets/list.go
Normal file
46
cli/cmd/secrets/list.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandList() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "list",
|
||||
Aliases: []string{},
|
||||
Usage: "List secrets in the cloud environment",
|
||||
Action: commandList,
|
||||
Flags: commonFlags(),
|
||||
}
|
||||
}
|
||||
|
||||
func commandList(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
cl, err := ce.GetNhostClient(cCtx.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
secrets, err := cl.GetSecrets(
|
||||
cCtx.Context,
|
||||
proj.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secrets: %w", err)
|
||||
}
|
||||
|
||||
for _, secret := range secrets.GetAppSecrets() {
|
||||
ce.Println("%s", secret.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
29
cli/cmd/secrets/secrets.go
Normal file
29
cli/cmd/secrets/secrets.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package secrets
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
const flagSubdomain = "subdomain"
|
||||
|
||||
func commonFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagSubdomain,
|
||||
Usage: "Project's subdomain to operate on, defaults to linked project",
|
||||
EnvVars: []string{"NHOST_SUBDOMAIN"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Command() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "secrets",
|
||||
Aliases: []string{},
|
||||
Usage: "Manage secrets",
|
||||
Subcommands: []*cli.Command{
|
||||
CommandCreate(),
|
||||
CommandDelete(),
|
||||
CommandList(),
|
||||
CommandUpdate(),
|
||||
},
|
||||
}
|
||||
}
|
||||
51
cli/cmd/secrets/update.go
Normal file
51
cli/cmd/secrets/update.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package secrets //nolint:dupl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandUpdate() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "update",
|
||||
ArgsUsage: "NAME VALUE",
|
||||
Aliases: []string{},
|
||||
Usage: "Update secret in the cloud environment",
|
||||
Action: commandUpdate,
|
||||
Flags: commonFlags(),
|
||||
}
|
||||
}
|
||||
|
||||
func commandUpdate(cCtx *cli.Context) error {
|
||||
if cCtx.NArg() != 2 { //nolint:mnd
|
||||
return errors.New("invalid number of arguments") //nolint:err113
|
||||
}
|
||||
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
proj, err := ce.GetAppInfo(cCtx.Context, cCtx.String(flagSubdomain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app info: %w", err)
|
||||
}
|
||||
|
||||
cl, err := ce.GetNhostClient(cCtx.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
if _, err := cl.UpdateSecret(
|
||||
cCtx.Context,
|
||||
proj.ID,
|
||||
cCtx.Args().Get(0),
|
||||
cCtx.Args().Get(1),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to update secret: %w", err)
|
||||
}
|
||||
|
||||
ce.Infoln("Secret updated successfully!")
|
||||
|
||||
return nil
|
||||
}
|
||||
20
cli/cmd/software/software.go
Normal file
20
cli/cmd/software/software.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package software
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
const (
|
||||
devVersion = "dev"
|
||||
)
|
||||
|
||||
func Command() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "sw",
|
||||
Aliases: []string{},
|
||||
Usage: "Perform software management operations",
|
||||
Subcommands: []*cli.Command{
|
||||
CommandUninstall(),
|
||||
CommandUpgrade(),
|
||||
CommandVersion(),
|
||||
},
|
||||
}
|
||||
}
|
||||
67
cli/cmd/software/uninstall.go
Normal file
67
cli/cmd/software/uninstall.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package software
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
forceFlag = "force"
|
||||
)
|
||||
|
||||
func CommandUninstall() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "uninstall",
|
||||
Aliases: []string{},
|
||||
Usage: "Remove the installed CLI from system permanently",
|
||||
Action: commandUninstall,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{ //nolint:exhaustruct
|
||||
Name: forceFlag,
|
||||
Usage: "Force uninstall without confirmation",
|
||||
EnvVars: []string{"NHOST_FORCE_UNINSTALL"},
|
||||
DefaultText: "false",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandUninstall(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
path, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find installed CLI: %w", err)
|
||||
}
|
||||
|
||||
if cCtx.App.Version == devVersion || cCtx.App.Version == "" {
|
||||
// we fake it in dev mode
|
||||
path = "/tmp/nhost"
|
||||
}
|
||||
|
||||
ce.Infoln("Found Nhost cli in %s", path)
|
||||
|
||||
if !cCtx.Bool(forceFlag) {
|
||||
ce.PromptMessage("Are you sure you want to uninstall Nhost CLI? [y/N] ")
|
||||
|
||||
resp, err := ce.PromptInput(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read user input: %w", err)
|
||||
}
|
||||
|
||||
if resp != "y" && resp != "Y" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ce.Infoln("Uninstalling Nhost CLI...")
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("failed to remove CLI: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
105
cli/cmd/software/upgrade.go
Normal file
105
cli/cmd/software/upgrade.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package software
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/software"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandUpgrade() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "upgrade",
|
||||
Aliases: []string{},
|
||||
Usage: "Upgrade the CLI to the latest version",
|
||||
Action: commandUpgrade,
|
||||
}
|
||||
}
|
||||
|
||||
func commandUpgrade(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
mgr := software.NewManager()
|
||||
|
||||
releases, err := mgr.GetReleases(cCtx.Context, cCtx.App.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get releases: %w", err)
|
||||
}
|
||||
|
||||
if len(releases) == 0 {
|
||||
ce.Infoln("You have the latest version. Hurray!")
|
||||
return nil
|
||||
}
|
||||
|
||||
latest := releases[0]
|
||||
if latest.TagName == cCtx.App.Version {
|
||||
ce.Infoln("You have the latest version. Hurray!")
|
||||
return nil
|
||||
}
|
||||
|
||||
ce.Infoln("Upgrading to %s...", latest.TagName)
|
||||
|
||||
version := latest.TagName
|
||||
s := strings.Split(latest.TagName, "@")
|
||||
|
||||
if len(s) == 2 { //nolint:mnd
|
||||
version = s[1]
|
||||
}
|
||||
|
||||
want := fmt.Sprintf("cli-%s-%s-%s.tar.gz", version, runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
var url string
|
||||
|
||||
for _, asset := range latest.Assets {
|
||||
if asset.Name == want {
|
||||
url = asset.BrowserDownloadURL
|
||||
}
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
return fmt.Errorf("failed to find asset for %s", want) //nolint:err113
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp(os.TempDir(), "nhost-cli-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
if err := mgr.DownloadAsset(cCtx.Context, url, tmpFile); err != nil {
|
||||
return fmt.Errorf("failed to download asset: %w", err)
|
||||
}
|
||||
|
||||
return install(cCtx, ce, tmpFile.Name())
|
||||
}
|
||||
|
||||
func install(cCtx *cli.Context, ce *clienv.CliEnv, tmpFile string) error {
|
||||
curBin, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find installed CLI: %w", err)
|
||||
}
|
||||
|
||||
if cCtx.App.Version == devVersion || cCtx.App.Version == "" {
|
||||
// we are in dev mode, we fake curBin for testing
|
||||
curBin = "/tmp/nhost"
|
||||
}
|
||||
|
||||
ce.Infoln("Copying to %s...", curBin)
|
||||
|
||||
if err := os.Rename(tmpFile, curBin); err != nil {
|
||||
return fmt.Errorf("failed to rename %s to %s: %w", tmpFile, curBin, err)
|
||||
}
|
||||
|
||||
ce.Infoln("Setting permissions...")
|
||||
|
||||
if err := os.Chmod(curBin, 0o755); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to set permissions on %s: %w", curBin, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
161
cli/cmd/software/version.go
Normal file
161
cli/cmd/software/version.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package software
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/nhost/be/services/mimir/model"
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/nhost/nhost/cli/cmd/config"
|
||||
"github.com/nhost/nhost/cli/nhostclient/graphql"
|
||||
"github.com/nhost/nhost/cli/project/env"
|
||||
"github.com/nhost/nhost/cli/software"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CommandVersion() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "version",
|
||||
Aliases: []string{},
|
||||
Usage: "Show the current version of Nhost CLI you have installed",
|
||||
Action: commandVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func checkCLIVersion(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
curVersion string,
|
||||
) error {
|
||||
mgr := software.NewManager()
|
||||
|
||||
releases, err := mgr.GetReleases(ctx, curVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get releases: %w", err)
|
||||
}
|
||||
|
||||
if len(releases) == 0 {
|
||||
ce.Infoln(
|
||||
"✅ Nhost CLI %s for %s-%s is already on the latest version",
|
||||
curVersion, runtime.GOOS, runtime.GOARCH,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
latest := releases[0]
|
||||
if latest.TagName == curVersion {
|
||||
return nil
|
||||
}
|
||||
|
||||
ce.Warnln("🟡 A new version of Nhost CLI is available: %s", latest.TagName)
|
||||
ce.Println(" You can upgrade the CLI by running `nhost sw upgrade`")
|
||||
ce.Println(" More info: https://github.com/nhost/nhost/cli/releases")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkServiceVersion(
|
||||
ce *clienv.CliEnv,
|
||||
software graphql.SoftwareTypeEnum,
|
||||
curVersion string,
|
||||
availableVersions *graphql.GetSoftwareVersions,
|
||||
changelog string,
|
||||
) {
|
||||
recommendedVersions := make([]string, 0, 5) //nolint:mnd
|
||||
|
||||
for _, v := range availableVersions.GetSoftwareVersions() {
|
||||
if *v.GetSoftware() == software && v.GetVersion() == curVersion {
|
||||
ce.Infoln("✅ %s is already on a recommended version: %s", software, curVersion)
|
||||
return
|
||||
} else if *v.GetSoftware() == software {
|
||||
recommendedVersions = append(recommendedVersions, v.GetVersion())
|
||||
}
|
||||
}
|
||||
|
||||
ce.Warnln(
|
||||
"🟡 %s is not on a recommended version. Recommended: %s",
|
||||
software, strings.Join(recommendedVersions, ", "),
|
||||
)
|
||||
|
||||
if changelog != "" {
|
||||
ce.Println(" More info: %s", changelog)
|
||||
}
|
||||
}
|
||||
|
||||
func CheckVersions(
|
||||
ctx context.Context,
|
||||
ce *clienv.CliEnv,
|
||||
cfg *model.ConfigConfig,
|
||||
appVersion string,
|
||||
) error {
|
||||
var secrets model.Secrets
|
||||
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to parse secrets, make sure secret values are between quotes: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cl, err := ce.GetNhostPublicClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get nhost client: %w", err)
|
||||
}
|
||||
|
||||
swv, err := cl.GetSoftwareVersions(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get software versions: %w", err)
|
||||
}
|
||||
|
||||
checkServiceVersion(
|
||||
ce, graphql.SoftwareTypeEnumAuth, *cfg.GetAuth().GetVersion(), swv,
|
||||
"https://github.com/nhost/hasura-auth/releases",
|
||||
)
|
||||
checkServiceVersion(
|
||||
ce, graphql.SoftwareTypeEnumStorage, *cfg.GetStorage().GetVersion(), swv,
|
||||
"https://github.com/nhost/hasura-storage/releases",
|
||||
)
|
||||
checkServiceVersion(
|
||||
ce, graphql.SoftwareTypeEnumPostgreSQL, *cfg.GetPostgres().GetVersion(), swv,
|
||||
"https://hub.docker.com/r/nhost/postgres",
|
||||
)
|
||||
checkServiceVersion(ce, graphql.SoftwareTypeEnumHasura, *cfg.GetHasura().GetVersion(), swv, "")
|
||||
|
||||
if cfg.GetAi() != nil {
|
||||
checkServiceVersion(
|
||||
ce, graphql.SoftwareTypeEnumGraphite, *cfg.GetAi().GetVersion(), swv, "",
|
||||
)
|
||||
}
|
||||
|
||||
return checkCLIVersion(ctx, ce, appVersion)
|
||||
}
|
||||
|
||||
func commandVersion(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
|
||||
var (
|
||||
cfg *model.ConfigConfig
|
||||
err error
|
||||
)
|
||||
|
||||
if clienv.PathExists(ce.Path.NhostToml()) && clienv.PathExists(ce.Path.Secrets()) {
|
||||
var secrets model.Secrets
|
||||
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to parse secrets, make sure secret values are between quotes: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cfg, err = config.Validate(ce, "local", secrets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate config: %w", err)
|
||||
}
|
||||
} else {
|
||||
ce.Warnln("🟡 No Nhost project found")
|
||||
}
|
||||
|
||||
return CheckVersions(cCtx.Context, ce, cfg, cCtx.App.Version)
|
||||
}
|
||||
47
cli/cmd/user/login.go
Normal file
47
cli/cmd/user/login.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/nhost/nhost/cli/clienv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
flagEmail = "email"
|
||||
flagPassword = "password"
|
||||
flagPAT = "pat"
|
||||
)
|
||||
|
||||
func CommandLogin() *cli.Command {
|
||||
return &cli.Command{ //nolint:exhaustruct
|
||||
Name: "login",
|
||||
Aliases: []string{},
|
||||
Usage: "Login to Nhost",
|
||||
Action: commandLogin,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagPAT,
|
||||
Usage: "Use this Personal Access Token instead of generating a new one with your email/password",
|
||||
EnvVars: []string{"NHOST_PAT"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagEmail,
|
||||
Usage: "Email address",
|
||||
EnvVars: []string{"NHOST_EMAIL"},
|
||||
},
|
||||
&cli.StringFlag{ //nolint:exhaustruct
|
||||
Name: flagPassword,
|
||||
Usage: "Password",
|
||||
EnvVars: []string{"NHOST_PASSWORD"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandLogin(cCtx *cli.Context) error {
|
||||
ce := clienv.FromCLI(cCtx)
|
||||
_, err := ce.Login(
|
||||
cCtx.Context, cCtx.String(flagPAT), cCtx.String(flagEmail), cCtx.String(flagPassword),
|
||||
)
|
||||
|
||||
return err //nolint:wrapcheck
|
||||
}
|
||||
1
cli/cmd/user/user.go
Normal file
1
cli/cmd/user/user.go
Normal file
@@ -0,0 +1 @@
|
||||
package user
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user