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:
David Barroso
2025-09-26 08:37:28 +02:00
committed by GitHub
parent 72401ae1a7
commit 0c820d4173
6943 changed files with 2479083 additions and 292 deletions

View File

@@ -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

View File

@@ -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'

View File

@@ -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
View 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
View 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()

View 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()

View File

@@ -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>

View File

@@ -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
View File

@@ -71,3 +71,5 @@ result
.vitest
.claude
letsencrypt/*

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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}}

View 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
View 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 }}

View 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

View 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']"

View 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
View 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 }}

View 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 }}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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'

View 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 }}'

View 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
View 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
}

View 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)
}
})
}
}

View 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
}

View 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
}

View 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)
}
})
}
}

View 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")
}

View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
}

View 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(),
},
}
}

View 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
}

View 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
}

View 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
View 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
View 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
View 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
}

View File

@@ -0,0 +1 @@
package project

View File

@@ -0,0 +1,2 @@
.nhost
.secrets

View 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": {}
}
}
}

View 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"
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
}

View 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(),
},
}
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
package user

Some files were not shown because too many files have changed in this diff Show More