From 0e18086695fb631b4e18f3a7ed7f22e1ba2ffda0 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Wed, 5 Aug 2026 08:40:15 -0600 Subject: [PATCH] Replace setup.sh + nginx.sh with huly-setup (Go + Bubble Tea) --- .github/workflows/release.yaml | 97 +++++ Makefile | 29 ++ README.md | 44 +- cmd/huly-setup/README.md | 166 ++++++++ cmd/huly-setup/main.go | 303 ++++++++++++++ go.mod | 32 ++ go.sum | 53 +++ internal/compose/compose.go | 214 ++++++++++ internal/compose/compose_test.go | 162 ++++++++ internal/compose/multi.tmpl | 240 +++++++++++ internal/compose/single.tmpl | 401 ++++++++++++++++++ internal/config/config.go | 210 ++++++++++ internal/config/config_test.go | 137 ++++++ internal/config/loadsave.go | 193 +++++++++ internal/docker/docker.go | 208 ++++++++++ internal/docker/docker_test.go | 73 ++++ internal/envconf/envconf.go | 119 ++++++ internal/envconf/envconf_test.go | 56 +++ internal/nginx/container.conf | 89 ++++ internal/nginx/nginx.go | 67 +++ internal/nginx/nginx_test.go | 65 +++ internal/nginx/upstream.conf | 104 +++++ internal/profile/profile.go | 81 ++++ internal/profile/profile_test.go | 26 ++ internal/runner/runner.go | 300 +++++++++++++ internal/runner/runner_test.go | 108 +++++ internal/secrets/secrets.go | 48 +++ internal/secrets/secrets_test.go | 49 +++ internal/tui/e2e_test.go | 180 ++++++++ internal/tui/model.go | 693 +++++++++++++++++++++++++++++++ internal/tui/model_test.go | 153 +++++++ internal/tui/styles.go | 94 +++++ internal/tui/tui.go | 35 ++ nginx.sh | 78 ---- scripts/install.sh | 100 +++++ setup.sh | 297 +------------ 36 files changed, 4940 insertions(+), 364 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 Makefile create mode 100644 cmd/huly-setup/README.md create mode 100644 cmd/huly-setup/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/compose/compose.go create mode 100644 internal/compose/compose_test.go create mode 100644 internal/compose/multi.tmpl create mode 100644 internal/compose/single.tmpl create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/loadsave.go create mode 100644 internal/docker/docker.go create mode 100644 internal/docker/docker_test.go create mode 100644 internal/envconf/envconf.go create mode 100644 internal/envconf/envconf_test.go create mode 100644 internal/nginx/container.conf create mode 100644 internal/nginx/nginx.go create mode 100644 internal/nginx/nginx_test.go create mode 100644 internal/nginx/upstream.conf create mode 100644 internal/profile/profile.go create mode 100644 internal/profile/profile_test.go create mode 100644 internal/runner/runner.go create mode 100644 internal/runner/runner_test.go create mode 100644 internal/secrets/secrets.go create mode 100644 internal/secrets/secrets_test.go create mode 100644 internal/tui/e2e_test.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/model_test.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/tui.go delete mode 100755 nginx.sh create mode 100755 scripts/install.sh diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..ea2ef95b --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,97 @@ +name: release + +on: + push: + tags: + - 'huly-setup-v*' + workflow_dispatch: + inputs: + version: + description: 'Tag suffix (e.g. v0.1.0 → creates tag huly-setup-v0.1.0)' + required: false + default: '' + +permissions: + contents: write + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + - name: go test ./... + run: go test ./... + + build: + needs: test + strategy: + fail-fast: false + matrix: + include: + - target: linux_amd64 + goos: linux + goarch: amd64 + - target: linux_arm64 + goos: linux + goarch: arm64 + - target: darwin_amd64 + goos: darwin + goarch: amd64 + - target: darwin_arm64 + goos: darwin + goarch: arm64 + - target: windows_amd64 + goos: windows + goarch: amd64 + ext: .exe + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + ext="${EXT:-}" + mkdir -p dist + go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ + -o "dist/huly-setup${ext}" ./cmd/huly-setup + cd dist + tar -czf "../huly-setup_${VERSION}_${{ matrix.target }}.tar.gz" \ + "huly-setup${ext}" ../scripts/install.sh + if [ "${RUNNER_OS}" = "Windows" ] || [ "${{ matrix.goos }}" = "windows" ]; then + zip -q "../huly-setup_${VERSION}_${{ matrix.target }}.zip" "huly-setup${ext}" + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: huly-setup-${{ matrix.target }} + path: huly-setup_${{ github.ref_name }}_${{ matrix.target }}.* + + release: + needs: build + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/* + generate_release_notes: true + draft: true diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..de28ce54 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +GO ?= go +BINARY ?= huly-setup +DIST ?= dist + +.PHONY: build test lint fmt clean run install + +build: + $(GO) build -trimpath -ldflags "-s -w" -o $(BINARY) ./cmd/huly-setup + +test: + $(GO) test ./... -count=1 + +fmt: + $(GO) fmt ./... + +vet: + $(GO) vet ./... + +clean: + rm -f $(BINARY) + rm -rf $(DIST) + +run: build + ./$(BINARY) + +install: build + install -d $$HOME/.local/bin + install -m 0755 $(BINARY) $$HOME/.local/bin/$(BINARY) + @echo "Installed to $$HOME/.local/bin/$(BINARY)" diff --git a/README.md b/README.md index f37f9356..20e0f994 100644 --- a/README.md +++ b/README.md @@ -74,25 +74,40 @@ For detailed information about the Huly self-hosted architecture, services, and ## Quick Start (Local Testing) -For fast local verification without going through the full setup process: +For fast local verification without going through the full setup process, +the new Go `huly-setup` tool replaces the legacy `setup.sh`: ```bash git clone https://github.com/hcengineering/huly-selfhost.git cd huly-selfhost -./setup.sh --quick +./huly-setup --quick # builds via `make build`, or just `go build -o huly-setup ./cmd/huly-setup` +``` + +Or install the prebuilt binary in one line: + +```bash +curl -fsSL https://raw.githubusercontent.com/hcengineering/huly-selfhost/main/scripts/install.sh | bash ``` This will: - Use `localhost:8087` as the host address -- Skip all configuration prompts -- Use default Docker volumes +- Skip all configuration prompts (or drop into the Bubble Tea TUI for the + interactive flow — same defaults either way) +- Apply the single-tenant memory profile - Automatically start all services Access Huly at **http://localhost:8087** (wait ~60 seconds for services to initialize). To stop all services, run `docker compose down` from the `huly-selfhost` folder. +> [!TIP] +> Run `./huly-setup --dry-run` first to preview the generated `compose.yml`, +> `huly_v7.conf`, and nginx config without writing any files. + > [!NOTE] > Quick start is intended for local testing only. For production deployments, follow the full setup instructions below. +See `cmd/huly-setup/README.md` for the full flag reference and the +`single-tenant` / `behind-reverse-proxy` modes. + ## Installing `nginx` and `docker` First, update repositories cache: @@ -117,7 +132,15 @@ Next, let's clone the `huly-selfhost` repository and configure Huly. ```bash git clone https://github.com/hcengineering/huly-selfhost.git cd huly-selfhost -./setup.sh +./huly-setup +``` + +Or, if you don't have a Go toolchain handy, download a prebuilt binary +(recommended for production hosts): + +```bash +curl -fsSL https://raw.githubusercontent.com/hcengineering/huly-selfhost/main/scripts/install.sh | bash +huly-setup ``` This will generate a [huly_v7.conf](./huly_v7.conf) file with your chosen values and create your nginx config. @@ -130,12 +153,11 @@ sudo ln -s $(pwd)/nginx.conf /etc/nginx/sites-enabled/huly.conf > [!NOTE] > If you change `HOST_ADDRESS`, `SECURE`, `HTTP_PORT` or `HTTP_BIND` be sure to update your [nginx.conf](./nginx.conf) -> by running: +> by re-running: > ```bash -> ./nginx.sh +> ./huly-setup > ``` ->You can safely execute this script after adding your custom configurations like ssl. It will only overwrite the -> necessary settings. +> The tool reuses your existing config (load + save) so re-running only rewrites the fields you've changed. Finally, let's reload `nginx` and start Huly with `docker compose`. @@ -175,7 +197,7 @@ By default, Huly uses Docker named volumes to store persistent data (database, E ### During Setup -When running `./setup.sh`, you'll be prompted to specify custom paths for: +When running `./huly-setup`, you'll be prompted to specify custom paths for: - **Elasticsearch volume**: Search index data storage - **Files volume**: User-uploaded files and attachments @@ -197,7 +219,7 @@ You can either: To quickly reset all volumes back to default Docker named volumes without prompts: ```bash -./setup.sh --reset-volumes +./huly-setup --reset-volumes ``` ### Manual Configuration diff --git a/cmd/huly-setup/README.md b/cmd/huly-setup/README.md new file mode 100644 index 00000000..13d17539 --- /dev/null +++ b/cmd/huly-setup/README.md @@ -0,0 +1,166 @@ +# Huly Self-Host Setup (`huly-setup`) + +`huly-setup` is the new Go + Bubble Tea replacement for the legacy `setup.sh` +and `nginx.sh` scripts. It generates the same artifacts (`compose.yml`, +`huly_v7.conf`, `.huly.nginx`) but adds: + +- **Two deployment profiles** — `multi` (multi-tenant / upstream-equivalent) + and `single` (memory-tuned for one user on a small VPS). +- **Two network topologies** — `builtin` (in-stack nginx container) and + `reverse-proxy` (skips nginx, emits a paste-ready snippet for your existing + proxy). +- **Dry-run mode** that renders every file and prints the docker commands + without writing or executing anything — perfect for CI and previews. +- **First-class non-interactive mode** for scripts. +- **Cross-platform binary distribution** via GitHub Releases + a one-line + bash installer. + +## Quickstart + +```bash +# Interactive (Bubble Tea TUI): +./huly-setup + +# Same as the old ./setup.sh --quick: +./huly-setup --quick + +# Behind your own reverse proxy (no in-stack nginx), exposing services on +# 127.0.0.1 so the host's system nginx can reach them: +./huly-setup --only-render --non-interactive \ + --multi-tenant --host=huly.example.com --port=443 --tls \ + --behind-reverse-proxy --expose-mode=127.0.0.1 + +# Single-tenant, fully scripted: +./huly-setup --non-interactive \ + --single-tenant --host=huly.example.com --port=443 --tls + +# Just preview, do nothing: +./huly-setup --dry-run --single-tenant +``` + +## Distribution + +GitHub Actions (`.github/workflows/release.yaml`) cross-compiles the binary for +linux/darwin × amd64/arm64 + windows/amd64 and attaches the tarballs to the +release. Tag with `huly-setup-v*` to trigger: + +```bash +git tag huly-setup-v0.1.0 +git push origin huly-setup-v0.1.0 +``` + +Users install with one line: + +```bash +curl -fsSL https://raw.githubusercontent.com/hcengineering/huly-selfhost/main/scripts/install.sh | bash +``` + +This downloads the matching binary, drops it into `~/.local/bin/huly-setup`, +and launches it. Pin a version with `HULY_SETUP_VERSION=huly-setup-v0.1.0`. + +## Profiles in detail + +### `single` (recommended for self-host) + +Cherry-picks the memory & logging tuning from the upstream huly-selfhost +branch: + +- ~4 GB RAM at idle, ~6 GB under load +- Per-service memory limits (`deploy.resources.limits.memory`) +- Per-service log rotation (3m–30m × 3–5 files) +- `GOGC=200` for cockroach +- Elastic heap dropped from 1 GB → 768 MB +- Redpanda constrained to 256 MB +- `DISABLED_FEATURES=auto-translate,mailboxes,signup,passwords,recover` + (signup/passwords/recover don't make sense for a single owner) +- `INIT_REPO_DIR=/no-init-scripts` so new workspaces aren't seeded with + example content + +### `multi` + +Upstream-equivalent — no memory caps, no log rotation, no feature disabling. +Use this if you want the same behaviour as the upstream `hcengineering/huly-selfhost` +and don't mind the resource cost. + +## Topologies + +### `builtin` + +The compose stack ships an `nginx:1.21.3` container that bind-mounts +`.huly.nginx` and forwards to `front:8080`, `account:3000`, etc. via the +internal `huly_net` bridge. This is what the legacy `setup.sh` always did. + +### `reverse-proxy` + +The `nginx:` service is removed from the generated compose. You get a +`reverse-proxy.conf` snippet instead. After selecting this topology the TUI +(or `--expose-mode` flag) asks how the proxy reaches the services: + +| Expose mode | Compose port bindings | Snippet upstream targets | +|---|---|---| +| `127.0.0.1` (default, recommended for system nginx / caddy / traefik on the host) | `127.0.0.1:8080:8080`, `127.0.0.1:3000:3000`, ... | `server 127.0.0.1:8080`, ... | +| `0.0.0.0` (proxy is on a different host that can reach this one) | `0.0.0.0:8080:8080`, ... | `server 0.0.0.0:8080`, ... | +| `network` (proxy joins the `huly_net` docker network) | none (services only on the docker network) | `server front:8080`, ... | + +Paste the snippet into your existing nginx server block (or translate for +caddy / traefik). + +## Command-line flags + +``` + --bind string bind IP (defaults to 0.0.0.0) + --dry-run render files and log docker commands without writing/executing + --help show help and exit + --host string public host (domain or IP) + --huly-version string Huly platform version (e.g. v0.7.426) + --language string default language + --no-tls disable HTTPS + --non-interactive use flags only, never prompt; require all values + --only-render render files but don't run docker compose up + --port int public port + --profile string deployment profile: multi or single + --quick use defaults, skip prompts, start immediately + --reset-volumes clear all volume host-path overrides and exit + --rotate-secrets regenerate .huly.secret/.cr.secret/.rp.secret + --single-tenant alias for --profile=single + --behind-reverse-proxy alias for --topology=reverse-proxy + --bind-mode string alias for --expose-mode (127.0.0.1|0.0.0.0|network) + --expose-mode string reverse-proxy expose mode: 127.0.0.1, 0.0.0.0, or network (only with --topology=reverse-proxy) + --multi-tenant alias for --profile=multi + --skip-pull don't pull images before up + --skip-up don't run docker compose up + --title string instance title + --tls enable HTTPS + --topology string network topology: builtin or reverse-proxy + --version print version and exit + --volume-cr-certs string host path for cockroachdb certs volume + --volume-cr-data string host path for cockroachdb data volume + --volume-elastic string host path for elasticsearch volume + --volume-files string host path for files volume + --volume-redpanda string host path for redpanda volume +``` + +## Development + +```bash +make build # ./huly-setup +make test # go test ./... -count=1 +make vet +``` + +Templates are embedded into the binary via `//go:embed`. If you change a +template under `internal/compose/*.tmpl`, `internal/nginx/*.conf`, or +`internal/envconf/envconf.go`, rebuild. + +## Migrating from the old `setup.sh` + +The new tool generates the same file layout, so existing `huly_v7.conf`, +`.huly.secret`, `.cr.secret`, `.rp.secret` are picked up — re-running the new +tool on an existing checkout will only overwrite fields you've changed. + +The legacy `setup.sh` and `nginx.sh` are kept around for reference but are no +longer maintained. Delete them after you've migrated: + +```bash +rm setup.sh nginx.sh .template.huly.conf .template.nginx.conf +``` diff --git a/cmd/huly-setup/main.go b/cmd/huly-setup/main.go new file mode 100644 index 00000000..4ccae7ed --- /dev/null +++ b/cmd/huly-setup/main.go @@ -0,0 +1,303 @@ +// Command huly-setup renders the Huly self-host compose/env/nginx stack and +// (optionally) brings it up with docker compose. +// +// Usage: +// +// huly-setup # interactive Bubble Tea UI +// huly-setup --quick # localhost:8087, no TLS, defaults +// huly-setup --dry-run --single-tenant # show what would happen +// huly-setup --non-interactive --single-tenant --host=huly.example.com --tls +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/mattn/go-isatty" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/docker" + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/runner" + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/tui" +) + +var version = "dev" + +func main() { + if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "huly-setup: %v\n", err) + os.Exit(1) + } +} + +type parsedFlags struct { + Quick bool + DryRun bool + NonInteractive bool + RotateSecrets bool + OnlyRender bool + SkipPull bool + SkipUp bool + Profile string + Topology string + ExposeMode string + BindMode string + SingleTenant bool + MultiTenant bool + BehindProxy bool + Host string + Port int + Bind string + Secure bool + Insecure bool + Title string + Language string + Version string + VolumeElastic string + VolumeFiles string + VolumeCRData string + VolumeCRCerts string + VolumeRedpanda string + ResetVolumes bool + ShowVersion bool + ShowHelp bool +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("huly-setup", flag.ContinueOnError) + fs.SetOutput(stderr) + var p parsedFlags + fs.BoolVar(&p.Quick, "quick", false, "use defaults, skip prompts, start immediately") + fs.BoolVar(&p.DryRun, "dry-run", false, "render files and log docker commands without writing/executing") + fs.BoolVar(&p.NonInteractive, "non-interactive", false, "use flags only, never prompt; require all values") + fs.BoolVar(&p.RotateSecrets, "rotate-secrets", false, "regenerate .huly.secret/.cr.secret/.rp.secret") + fs.BoolVar(&p.OnlyRender, "only-render", false, "render files but don't run docker compose up") + fs.BoolVar(&p.SkipPull, "skip-pull", false, "don't pull images before up") + fs.BoolVar(&p.SkipUp, "skip-up", false, "don't run docker compose up") + fs.StringVar(&p.Profile, "profile", "", "deployment profile: multi or single") + fs.StringVar(&p.Topology, "topology", "", "network topology: builtin or reverse-proxy") + fs.StringVar(&p.ExposeMode, "expose-mode", "", "reverse-proxy expose mode: 127.0.0.1, 0.0.0.0, or network (only with --topology=reverse-proxy)") + fs.BoolVar(&p.SingleTenant, "single-tenant", false, "alias for --profile=single") + fs.BoolVar(&p.MultiTenant, "multi-tenant", false, "alias for --profile=multi") + fs.BoolVar(&p.BehindProxy, "behind-reverse-proxy", false, "alias for --topology=reverse-proxy") + fs.StringVar(&p.BindMode, "bind-mode", "", "alias for --expose-mode when behind a reverse proxy (127.0.0.1|0.0.0.0|network)") + fs.StringVar(&p.Host, "host", "", "public host (domain or IP)") + fs.IntVar(&p.Port, "port", 0, "public port") + fs.StringVar(&p.Bind, "bind", "", "bind IP (defaults to 0.0.0.0)") + fs.BoolVar(&p.Secure, "tls", false, "enable HTTPS") + fs.BoolVar(&p.Insecure, "no-tls", false, "disable HTTPS") + fs.StringVar(&p.Title, "title", "", "instance title") + fs.StringVar(&p.Language, "language", "", "default language") + fs.StringVar(&p.Version, "huly-version", "", "Huly platform version (e.g. v0.7.426)") + fs.StringVar(&p.VolumeElastic, "volume-elastic", "", "host path for elasticsearch volume") + fs.StringVar(&p.VolumeFiles, "volume-files", "", "host path for files volume") + fs.StringVar(&p.VolumeCRData, "volume-cr-data", "", "host path for cockroachdb data volume") + fs.StringVar(&p.VolumeCRCerts, "volume-cr-certs", "", "host path for cockroachdb certs volume") + fs.StringVar(&p.VolumeRedpanda, "volume-redpanda", "", "host path for redpanda volume") + fs.BoolVar(&p.ResetVolumes, "reset-volumes", false, "clear all volume host-path overrides and exit") + fs.BoolVar(&p.ShowVersion, "version", false, "print version and exit") + fs.BoolVar(&p.ShowHelp, "help", false, "show help and exit") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + if p.ShowVersion { + fmt.Fprintf(stdout, "huly-setup %s\n", version) + return nil + } + if p.ShowHelp { + fs.Usage() + return nil + } + + dir, err := config.HulyDir() + if err != nil { + return err + } + configPath := filepath.Join(dir, config.ConfigFileName) + + if p.ResetVolumes { + return resetVolumes(configPath) + } + + // Load existing config so users only need to specify the fields they want + // to change. + existing, _ := config.Load(configPath) + merged := mergeConfig(existing, p) + merged.ApplyDefaults() + + if p.DryRun { + merged.DryRun = true + } + if p.SkipPull { + merged.SkipPull = true + } + if p.SkipUp { + merged.SkipUp = true + } + if p.Secure && p.Insecure { + return errors.New("--tls and --no-tls are mutually exclusive") + } + + if p.Quick { + if merged.HostAddress == "" { + merged.HostAddress = "localhost" + } + if merged.HTTPPort == 0 { + merged.HTTPPort = 8087 + } + merged.Secure = false + if merged.Profile == "" { + merged.Profile = config.ProfileSingle + } + if merged.Topology == "" { + merged.Topology = config.TopologyBuiltin + } + } + + if !p.NonInteractive && !p.Quick && !merged.DryRun && isTerminal(stdin) && !hasAllRequiredFlags(p) { + final, err := tui.Run(merged, stdin, stdout) + if errors.Is(err, tui.ErrAborted) { + fmt.Fprintln(stderr, "aborted.") + return nil + } + if err != nil { + return err + } + merged = final + merged.ApplyDefaults() + } else { + if !p.Quick && !p.NonInteractive && !merged.DryRun { + // stdin but no TTY: warn the user and continue with flag/default values. + fmt.Fprintln(stderr, "warning: stdin is not a TTY; falling back to non-interactive mode (use --non-interactive to silence this warning).") + } + if err := merged.Validate(); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + } + + // Build the invoker. In dry-run mode we use a bare DryRunInvoker (no + // Inner) so docker is never actually invoked; otherwise use the real CLI. + var inv docker.Invoker + if merged.DryRun { + inv = &docker.DryRunInvoker{Logger: func(s string) { fmt.Fprintln(stdout, s) }} + } else { + inv = &docker.CLIInvoker{Stdout: stdout, Stderr: stderr} + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + res, err := runner.Run(ctx, merged, runner.Options{ + OnlyRender: p.OnlyRender, + RotateSecrets: p.RotateSecrets, + }, inv, stdout) + if err != nil { + return err + } + if !merged.DryRun { + runner.PrintSummary(res, stdout) + } + return nil +} + +func mergeConfig(existing config.Config, p parsedFlags) config.Config { + c := existing + if p.Profile != "" { + c.Profile = config.Profile(p.Profile) + } + if p.Topology != "" { + c.Topology = config.NetworkTopology(p.Topology) + } + if p.SingleTenant { + c.Profile = config.ProfileSingle + } + if p.MultiTenant { + c.Profile = config.ProfileMulti + } + if p.BehindProxy { + c.Topology = config.TopologyReverse + } + if p.ExposeMode != "" { + c.ExposeMode = config.ExposeMode(p.ExposeMode) + } + if p.BindMode != "" { + c.ExposeMode = config.ExposeMode(p.BindMode) + } + if p.Host != "" { + c.HostAddress = p.Host + } + if p.Port != 0 { + c.HTTPPort = p.Port + } + if p.Bind != "" { + c.HTTPBind = p.Bind + } + if p.Secure { + c.Secure = true + } + if p.Insecure { + c.Secure = false + } + if p.Title != "" { + c.Title = p.Title + } + if p.Language != "" { + c.DefaultLanguage = p.Language + } + if p.Version != "" { + c.HulyVersion = p.Version + } + if p.VolumeElastic != "" { + c.VolumeElasticPath = p.VolumeElastic + } + if p.VolumeFiles != "" { + c.VolumeFilesPath = p.VolumeFiles + } + if p.VolumeCRData != "" { + c.VolumeCRDataPath = p.VolumeCRData + } + if p.VolumeCRCerts != "" { + c.VolumeCRCertsPath = p.VolumeCRCerts + } + if p.VolumeRedpanda != "" { + c.VolumeRedpanda = p.VolumeRedpanda + } + return c +} + +func hasAllRequiredFlags(p parsedFlags) bool { + return p.Host != "" && p.Port != 0 && p.Profile != "" && p.Topology != "" +} + +func resetVolumes(path string) error { + c, err := config.Load(path) + if err != nil { + return err + } + c.VolumeElasticPath = "" + c.VolumeFilesPath = "" + c.VolumeCRDataPath = "" + c.VolumeCRCertsPath = "" + c.VolumeRedpanda = "" + return config.Save(c, path) +} + +func isTerminal(r io.Reader) bool { + f, ok := r.(*os.File) + if !ok { + return false + } + return isatty.IsTerminal(f.Fd()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..d83b0619 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module github.com/hcengineering/huly-selfhost/cmd/huly-setup + +go 1.25.0 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/mattn/go-isatty v0.0.20 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..c19020e3 --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/compose/compose.go b/internal/compose/compose.go new file mode 100644 index 00000000..b029388a --- /dev/null +++ b/internal/compose/compose.go @@ -0,0 +1,214 @@ +// Package compose renders the docker-compose.yml for a given Config. +// +// Two templates are embedded: one for the multi-tenant profile (unbounded, +// upstream-equivalent) and one for the single-tenant profile (memory-tuned). +// The reverse-proxy topology strips the nginx service so the user's existing +// proxy can reach front:8080 etc. directly. +// +// Templates use docker-compose's native ${VAR} envsubst; the accompanying +// .env (huly_v7.conf) supplies the values at `docker compose up` time, so we +// don't need to interpolate here. +package compose + +import ( + _ "embed" + "fmt" + "strings" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +//go:embed multi.tmpl +var multiTmpl string + +//go:embed single.tmpl +var singleTmpl string + +func Render(c config.Config) (string, error) { + if err := c.Validate(); err != nil { + return "", err + } + body := multiTmpl + if c.Profile == config.ProfileSingle { + body = singleTmpl + } + if c.Topology == config.TopologyReverse { + body = stripServiceBlock(body, "nginx") + body = stripNginxDependsOn(body) + body = applyExposeMode(body, c.ExposeMode) + } + return body, nil +} + +type exposedService struct { + name string + port int +} + +// exposedServices are the internal service ports that a reverse proxy needs +// to reach. The service name must match the compose.yml key exactly. +var exposedServices = []exposedService{ + {"front", 8080}, + {"account", 3000}, + {"transactor", 3333}, + {"collaborator", 3078}, + {"rekoni", 4004}, + {"stats", 4900}, + {"minio", 9000}, +} + +// applyExposeMode injects a `ports:` block into each service that the proxy +// needs to reach. For ExposeNetwork no host ports are added — the proxy must +// join the docker network. For ExposeLocalhost and ExposeAll the services are +// bound to the host IP. +func applyExposeMode(body string, mode config.ExposeMode) string { + if mode == config.ExposeNetwork { + return body + } + bind := "" + switch mode { + case config.ExposeLocalhost: + bind = "127.0.0.1:" + case config.ExposeAll: + bind = "0.0.0.0:" + } + for _, svc := range exposedServices { + entry := fmt.Sprintf(" - \"%s%d:%d\"\n", bind, svc.port, svc.port) + body = injectPorts(body, svc.name, entry) + } + return body +} + +// injectPorts inserts `entry` (which must end with a newline) into the named +// service block, immediately before the existing `networks:` key if present +// or at the end of the service block otherwise. Idempotent: skips if the +// service already has a `ports:` block. +func injectPorts(body, service, entry string) string { + const ( + serviceIndent = " " + propertyIndent = " " + ) + target := serviceIndent + service + ":" + lines := strings.Split(body, "\n") + // find service block start/end + start := -1 + for i, l := range lines { + if l == target { + start = i + break + } + } + if start < 0 { + return body + } + end := len(lines) + for i := start + 1; i < len(lines); i++ { + l := lines[i] + if strings.HasPrefix(l, serviceIndent) && !strings.HasPrefix(l, propertyIndent) { + end = i + break + } + } + + // has ports already? + for i := start + 1; i < end; i++ { + if strings.HasPrefix(lines[i], propertyIndent+"ports:") { + return body + } + } + + // find insertion point: last `networks:` line in the block, or block end + insertAt := end + for i := start + 1; i < end; i++ { + if strings.HasPrefix(lines[i], propertyIndent+"networks:") { + insertAt = i + } + } + + portLines := []string{ + propertyIndent + "ports:", + strings.TrimRight(entry, "\n"), + } + + out := make([]string, 0, len(lines)+len(portLines)) + out = append(out, lines[:insertAt]...) + out = append(out, portLines...) + out = append(out, lines[insertAt:]...) + return strings.Join(out, "\n") +} + +// stripNginxDependsOn removes any `depends_on: nginx: ...` entries that might +// appear in other services after the nginx service itself has been removed. +func stripNginxDependsOn(body string) string { + lines := strings.Split(body, "\n") + out := make([]string, 0, len(lines)) + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + if !strings.HasSuffix(trimmed, "depends_on:") { + out = append(out, line) + continue + } + indent := line[:len(line)-len(strings.TrimLeft(line, " "))] + out = append(out, line) + for i+1 < len(lines) { + next := lines[i+1] + nextTrim := strings.TrimSpace(next) + if nextTrim == "" { + out = append(out, next) + i++ + continue + } + nextIndent := next[:len(next)-len(strings.TrimLeft(next, " "))] + if len(nextIndent) <= len(indent) { + break + } + key := strings.TrimSuffix(nextTrim, ":") + if key == "nginx" { + i++ + if i+1 < len(lines) { + cond := lines[i+1] + condIndent := cond[:len(cond)-len(strings.TrimLeft(cond, " "))] + if len(condIndent) > len(indent)+len(" ") { + i++ + } + } + continue + } + out = append(out, next) + i++ + } + } + return strings.Join(out, "\n") +} + +// stripServiceBlock removes a ` :` service block from a docker-compose +// YAML file by tracking indentation depth. +func stripServiceBlock(body, name string) string { + const serviceIndent = " " + target := serviceIndent + name + ":" + lines := strings.Split(body, "\n") + out := make([]string, 0, len(lines)) + inBlock := false + for _, line := range lines { + if !inBlock && line == target { + inBlock = true + continue + } + if inBlock { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(line, serviceIndent) && !strings.HasPrefix(line, " ") { + inBlock = false + out = append(out, line) + } else { + continue + } + continue + } + out = append(out, line) + } + return strings.Join(out, "\n") +} diff --git a/internal/compose/compose_test.go b/internal/compose/compose_test.go new file mode 100644 index 00000000..43a1ac12 --- /dev/null +++ b/internal/compose/compose_test.go @@ -0,0 +1,162 @@ +package compose + +import ( + "strings" + "testing" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +func baseConfig(t *testing.T) config.Config { + t.Helper() + c := config.Config{ + HostAddress: "huly.example.com", + HTTPPort: 443, + Secure: true, + } + c.ApplyDefaults() + return c +} + +func TestRenderMultiTenant(t *testing.T) { + c := baseConfig(t) + c.Profile = config.ProfileMulti + c.Topology = config.TopologyBuiltin + out, err := Render(c) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "name: ${DOCKER_NAME}", + "hardcoreeng/transactor:${HULY_VERSION}", + " nginx:", + "${HTTP_BIND:+${HTTP_BIND}:}${HTTP_PORT}:80", + "DISABLED_FEATURES=auto-translate,mailboxes", + } { + if !strings.Contains(out, want) { + t.Errorf("multi-tenant compose missing %q", want) + } + } +} + +func TestRenderSingleTenant(t *testing.T) { + c := baseConfig(t) + c.Profile = config.ProfileSingle + c.Topology = config.TopologyBuiltin + out, err := Render(c) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "--cache=256MiB", + "NODE_OPTIONS=--max-old-space-size=768", + "DISABLED_FEATURES=auto-translate,mailboxes,signup,passwords,recover", + "INIT_REPO_DIR=/no-init-scripts", + "memory: 1152M", + "memory: 1792M", + "x-logging-default", + "GOGC=200", + "MINIO_API_REQUESTS_MAX=256", + } { + if !strings.Contains(out, want) { + t.Errorf("single-tenant compose missing %q", want) + } + } +} + +func TestRenderReverseProxyStripsNginx(t *testing.T) { + c := baseConfig(t) + c.Profile = config.ProfileMulti + c.Topology = config.TopologyReverse + c.ExposeMode = config.ExposeNetwork + out, err := Render(c) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "\n nginx:\n") { + t.Errorf("reverse-proxy topology should drop the nginx service:\n%s", out) + } + for _, want := range []string{ + "hardcoreeng/front:", + "hardcoreeng/transactor:", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in reverse-proxy compose", want) + } + } +} + +func TestRenderReverseProxyLocalhostExpose(t *testing.T) { + c := baseConfig(t) + c.Profile = config.ProfileMulti + c.Topology = config.TopologyReverse + c.ExposeMode = config.ExposeLocalhost + out, err := Render(c) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `"127.0.0.1:8080:8080"`, + `"127.0.0.1:3000:3000"`, + `"127.0.0.1:3333:3333"`, + `"127.0.0.1:3078:3078"`, + `"127.0.0.1:9000:9000"`, + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in localhost expose mode", want) + } + } + if strings.Contains(out, "0.0.0.0:8080:8080") { + t.Errorf("localhost mode should not bind to 0.0.0.0") + } +} + +func TestRenderReverseProxyAllExpose(t *testing.T) { + c := baseConfig(t) + c.Profile = config.ProfileMulti + c.Topology = config.TopologyReverse + c.ExposeMode = config.ExposeAll + out, err := Render(c) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"0.0.0.0:8080:8080"`) { + t.Fatalf("expected 0.0.0.0 bind:\n%s", out) + } +} + +func TestRenderReverseProxyNetworkNoPorts(t *testing.T) { + c := baseConfig(t) + c.Profile = config.ProfileMulti + c.Topology = config.TopologyReverse + c.ExposeMode = config.ExposeNetwork + out, err := Render(c) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "8080:8080") { + t.Fatalf("network mode should not bind any host ports:\n%s", out) + } +} + +func TestRenderVolumeOverride(t *testing.T) { + c := baseConfig(t) + c.VolumeElasticPath = "/srv/elastic" + out, err := Render(c) + if err != nil { + t.Fatal(err) + } + // VOLUME_ELASTIC_PATH is expanded by docker compose from the .env file, + // so the rendered template still contains the literal envsubst expression + // pointing at VOLUME_ELASTIC_PATH. + if !strings.Contains(out, "${VOLUME_ELASTIC_PATH:-elastic}:/usr/share/elasticsearch/data") { + t.Fatalf("expected volume envsubst in compose:\n%s", out) + } +} + +func TestValidateFails(t *testing.T) { + c := config.Config{} + if _, err := Render(c); err == nil { + t.Fatal("expected validation error") + } +} diff --git a/internal/compose/multi.tmpl b/internal/compose/multi.tmpl new file mode 100644 index 00000000..44e4d9ae --- /dev/null +++ b/internal/compose/multi.tmpl @@ -0,0 +1,240 @@ +name: ${DOCKER_NAME} + +services: + nginx: + image: "nginx:1.21.3" + ports: + - "${HTTP_BIND:+${HTTP_BIND}:}${HTTP_PORT}:80" + volumes: + - ./.huly.nginx:/etc/nginx/conf.d/default.conf + restart: unless-stopped + networks: + - huly_net + + cockroach: + image: cockroachdb/cockroach:latest-v24.2 + command: start-single-node --accept-sql-without-tls + environment: + - COCKROACH_DATABASE=${CR_DATABASE} + - COCKROACH_USER=${CR_USERNAME} + - COCKROACH_PASSWORD=${CR_USER_PASSWORD} + volumes: + - ${VOLUME_CR_DATA_PATH:-cr_data}:/cockroach/cockroach-data + - ${VOLUME_CR_CERTS_PATH:-cr_certs}:/cockroach/certs + restart: unless-stopped + networks: + - huly_net + + redpanda: + image: docker.redpanda.com/redpandadata/redpanda:v24.3.6 + command: + - redpanda + - start + - --kafka-addr internal://0.0.0.0:9092,external://0.0.0.0:19092 + - --advertise-kafka-addr internal://redpanda:9092,external://localhost:19092 + - --pandaproxy-addr internal://0.0.0.0:8082,external://0.0.0.0:18082 + - --advertise-pandaproxy-addr internal://redpanda:8082,external://localhost:18082 + - --schema-registry-addr internal://0.0.0.0:8081,external://0.0.0.0:18081 + - --rpc-addr redpanda:33145 + - --advertise-rpc-addr redpanda:33145 + - --mode dev-container + - --smp 1 + - --default-log-level=info + volumes: + - ${VOLUME_REDPANDA_PATH:-redpanda}:/var/lib/redpanda/data + environment: + - REDPANDA_SUPERUSER_USERNAME=${REDPANDA_ADMIN_USER} + - REDPANDA_SUPERUSER_PASSWORD=${REDPANDA_ADMIN_PWD} + healthcheck: + test: ['CMD', 'rpk', 'cluster', 'info', '-X', 'user=${REDPANDA_ADMIN_USER}', '-X', 'pass=${REDPANDA_ADMIN_PWD}'] + interval: 10s + timeout: 5s + retries: 10 + networks: + - huly_net + + minio: + image: "minio/minio" + command: server /data --address ":9000" --console-address ":9001" + volumes: + - ${VOLUME_FILES_PATH:-files}:/data + healthcheck: + test: ['CMD', 'mc', 'ready', 'local'] + interval: 5s + retries: 10 + restart: unless-stopped + networks: + - huly_net + + elastic: + image: "elasticsearch:7.14.2" + command: | + /bin/sh -c "./bin/elasticsearch-plugin list | grep -q ingest-attachment || yes | ./bin/elasticsearch-plugin install --silent ingest-attachment; + /usr/local/bin/docker-entrypoint.sh eswrapper" + volumes: + - ${VOLUME_ELASTIC_PATH:-elastic}:/usr/share/elasticsearch/data + environment: + - ELASTICSEARCH_PORT_NUMBER=9200 + - BITNAMI_DEBUG=true + - discovery.type=single-node + - ES_JAVA_OPTS=-Xms1024m -Xmx1024m + - http.cors.enabled=true + - http.cors.allow-origin=http://localhost:8082 + healthcheck: + interval: 20s + retries: 10 + test: curl -s http://localhost:9200/_cluster/health | grep -vq '"status":"red"' + restart: unless-stopped + networks: + - huly_net + + rekoni: + image: hardcoreeng/rekoni-service:${HULY_VERSION} + environment: + - SECRET=${SECRET} + deploy: + resources: + limits: + memory: 500M + restart: unless-stopped + networks: + - huly_net + + transactor: + image: hardcoreeng/transactor:${HULY_VERSION} + environment: + - SERVER_PORT=3333 + - SERVER_SECRET=${SECRET} + - DB_URL=${CR_DB_URL} + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - FRONT_URL=http://localhost:8087 + - ACCOUNTS_URL=http://account:3000 + - FULLTEXT_URL=http://fulltext:4700 + - STATS_URL=http://stats:4900 + - LAST_NAME_FIRST=${LAST_NAME_FIRST:-true} + - QUEUE_CONFIG=redpanda:9092 + restart: unless-stopped + networks: + - huly_net + + collaborator: + image: hardcoreeng/collaborator:${HULY_VERSION} + environment: + - COLLABORATOR_PORT=3078 + - SECRET=${SECRET} + - ACCOUNTS_URL=http://account:3000 + - STATS_URL=http://stats:4900 + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + restart: unless-stopped + networks: + - huly_net + + account: + image: hardcoreeng/account:${HULY_VERSION} + environment: + - SERVER_PORT=3000 + - SERVER_SECRET=${SECRET} + - DB_URL=${CR_DB_URL} + - TRANSACTOR_URL=ws://transactor:3333;ws${SECURE:+s}://${HOST_ADDRESS}/_transactor + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - FRONT_URL=http${SECURE:+s}://${HOST_ADDRESS} + - STATS_URL=http${SECURE:+s}://${HOST_ADDRESS}/_stats + - MODEL_ENABLED=* + - ACCOUNTS_URL=http${SECURE:+s}://${HOST_ADDRESS}/_accounts + - ACCOUNT_PORT=3000 + - QUEUE_CONFIG=redpanda:9092 + restart: unless-stopped + networks: + - huly_net + + workspace: + image: hardcoreeng/workspace:${HULY_VERSION} + environment: + - SERVER_SECRET=${SECRET} + - DB_URL=${CR_DB_URL} + - TRANSACTOR_URL=ws://transactor:3333;ws${SECURE:+s}://${HOST_ADDRESS}/_transactor + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - MODEL_ENABLED=* + - ACCOUNTS_URL=http://account:3000 + - STATS_URL=http://stats:4900 + - QUEUE_CONFIG=redpanda:9092 + - ACCOUNTS_DB_URL=${CR_DB_URL} + restart: unless-stopped + networks: + - huly_net + + front: + image: hardcoreeng/front:${HULY_VERSION} + environment: + - SERVER_PORT=8080 + - SERVER_SECRET=${SECRET} + - LOVE_ENDPOINT=http${SECURE:+s}://${HOST_ADDRESS}/_love + - ACCOUNTS_URL=http${SECURE:+s}://${HOST_ADDRESS}/_accounts + - ACCOUNTS_URL_INTERNAL=http://account:3000 + - REKONI_URL=http${SECURE:+s}://${HOST_ADDRESS}/_rekoni + - CALENDAR_URL=http${SECURE:+s}://${HOST_ADDRESS}/_calendar + - GMAIL_URL=http${SECURE:+s}://${HOST_ADDRESS}/_gmail + - TELEGRAM_URL=http${SECURE:+s}://${HOST_ADDRESS}/_telegram + - STATS_URL=http${SECURE:+s}://${HOST_ADDRESS}/_stats + - UPLOAD_URL=/files + - ELASTIC_URL=http://elastic:9200 + - COLLABORATOR_URL=ws${SECURE:+s}://${HOST_ADDRESS}/_collaborator + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - TITLE=${TITLE:-Huly Self Host} + - DEFAULT_LANGUAGE=${DEFAULT_LANGUAGE:-en} + - LAST_NAME_FIRST=${LAST_NAME_FIRST:-true} + - DESKTOP_UPDATES_CHANNEL=${DESKTOP_CHANNEL} + - DISABLED_FEATURES=auto-translate,mailboxes + restart: unless-stopped + networks: + - huly_net + + fulltext: + image: hardcoreeng/fulltext:${HULY_VERSION} + environment: + - SERVER_SECRET=${SECRET} + - DB_URL=${CR_DB_URL} + - FULLTEXT_DB_URL=http://elastic:9200 + - ELASTIC_INDEX_NAME=huly_storage_index + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - REKONI_URL=http://rekoni:4004 + - ACCOUNTS_URL=http://account:3000 + - STATS_URL=http://stats:4900 + - QUEUE_CONFIG=redpanda:9092 + restart: unless-stopped + networks: + - huly_net + + stats: + image: hardcoreeng/stats:${HULY_VERSION} + environment: + - PORT=4900 + - SERVER_SECRET=${SECRET} + restart: unless-stopped + networks: + - huly_net + + kvs: + image: hardcoreeng/hulykvs:${HULY_VERSION} + depends_on: + cockroach: + condition: service_started + ports: + - 8094:8094 + environment: + - HULY_DB_CONNECTION=${CR_DB_URL} + - HULY_TOKEN_SECRET=${SECRET} + restart: unless-stopped + networks: + - huly_net + +volumes: + elastic: + files: + cr_data: + cr_certs: + redpanda: + mongodb: + +networks: + huly_net: diff --git a/internal/compose/single.tmpl b/internal/compose/single.tmpl new file mode 100644 index 00000000..795c02f5 --- /dev/null +++ b/internal/compose/single.tmpl @@ -0,0 +1,401 @@ +# ============================================================================= +# Single-tenant / single-host profile +# ----------------------------------------------------------------------------- +# Memory budgets tuned for ONE user running the entire stack on a small VPS. +# Stack footprint at idle is roughly 4 GB RAM; max ~6 GB during heavy load. +# NODE heap is ~75% of the container limit; the rest is jemalloc/native/buf. +# Logging is capped per-service so a chatty container can't fill the disk. +# Self-host-only features (signup, password reset, recover) are disabled. +# ============================================================================= + +name: ${DOCKER_NAME} + +x-logging-default: &logging-default + driver: json-file + options: + max-size: "3m" + max-file: "5" + +x-logging-7d: &logging-7d + driver: json-file + options: + max-size: "30m" + max-file: "5" + +x-logging-5m: &logging-5m + driver: json-file + options: + max-size: "5m" + max-file: "3" + +x-logging-elastic: &logging-elastic + driver: json-file + options: + max-size: "20m" + max-file: "5" + +x-logging-redpanda: &logging-redpanda + driver: json-file + options: + max-size: "10m" + max-file: "5" + +x-logging-nginx: &logging-nginx + driver: json-file + options: + max-size: "10m" + max-file: "5" + +services: + nginx: + image: "nginx:1.21.3" + ports: + - "${HTTP_BIND:+${HTTP_BIND}:}${HTTP_PORT}:80" + volumes: + - ./.huly.nginx:/etc/nginx/conf.d/default.conf + restart: unless-stopped + logging: *logging-nginx + deploy: + resources: + limits: + memory: 96M + reservations: + memory: 16M + networks: + - huly_net + + cockroach: + image: cockroachdb/cockroach:latest-v24.2 + command: + - start-single-node + - --accept-sql-without-tls + - --cache=256MiB + - --max-sql-memory=768MiB + environment: + - COCKROACH_DATABASE=${CR_DATABASE} + - COCKROACH_USER=${CR_USERNAME} + - COCKROACH_PASSWORD=${CR_USER_PASSWORD} + - GOGC=200 + volumes: + - ${VOLUME_CR_DATA_PATH:-cr_data}:/cockroach/cockroach-data + - ${VOLUME_CR_CERTS_PATH:-cr_certs}:/cockroach/certs + restart: unless-stopped + logging: *logging-7d + deploy: + resources: + limits: + memory: 1792M + reservations: + memory: 512M + networks: + - huly_net + + redpanda: + image: docker.redpanda.com/redpandadata/redpanda:v24.3.6 + command: + - redpanda + - start + - --kafka-addr internal://0.0.0.0:9092,external://0.0.0.0:19092 + - --advertise-kafka-addr internal://redpanda:9092,external://localhost:19092 + - --pandaproxy-addr internal://0.0.0.0:8082,external://0.0.0.0:18082 + - --advertise-pandaproxy-addr internal://redpanda:8082,external://localhost:18082 + - --schema-registry-addr internal://0.0.0.0:8081,external://0.0.0.0:18081 + - --rpc-addr redpanda:33145 + - --advertise-rpc-addr redpanda:33145 + - --mode dev-container + - --smp 1 + - --memory=256MiB + - --reserve-memory=64MiB + - --default-log-level=info + volumes: + - ${VOLUME_REDPANDA_PATH:-redpanda}:/var/lib/redpanda/data + environment: + - REDPANDA_SUPERUSER_USERNAME=${REDPANDA_ADMIN_USER} + - REDPANDA_SUPERUSER_PASSWORD=${REDPANDA_ADMIN_PWD} + logging: *logging-redpanda + healthcheck: + test: ['CMD', 'rpk', 'cluster', 'info', '-X', 'user=${REDPANDA_ADMIN_USER}', '-X', 'pass=${REDPANDA_ADMIN_PWD}'] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + deploy: + resources: + limits: + memory: 384M + reservations: + memory: 128M + networks: + - huly_net + + minio: + image: "minio/minio" + command: server /data --address ":9000" --console-address ":9001" --quiet + volumes: + - ${VOLUME_FILES_PATH:-files}:/data + environment: + - MINIO_API_REQUESTS_MAX=256 + healthcheck: + test: ['CMD', 'mc', 'ready', 'local'] + interval: 5s + retries: 10 + restart: unless-stopped + logging: *logging-default + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 64M + networks: + - huly_net + + elastic: + image: "elasticsearch:7.14.2" + command: | + /bin/sh -c "./bin/elasticsearch-plugin list | grep -q ingest-attachment || yes | ./bin/elasticsearch-plugin install --silent ingest-attachment; + /usr/local/bin/docker-entrypoint.sh eswrapper" + volumes: + - ${VOLUME_ELASTIC_PATH:-elastic}:/usr/share/elasticsearch/data + environment: + - ELASTICSEARCH_PORT_NUMBER=9200 + - BITNAMI_DEBUG=true + - discovery.type=single-node + - ES_JAVA_OPTS=-Xms512m -Xmx768m + - http.cors.enabled=true + - http.cors.allow-origin=http://localhost:8082 + - bootstrap.memory_lock=false + - indices.query.bool.max_clause_count=512 + healthcheck: + interval: 20s + retries: 10 + test: curl -s http://localhost:9200/_cluster/health | grep -vq '"status":"red"' + restart: unless-stopped + logging: *logging-elastic + deploy: + resources: + limits: + memory: 1024M + reservations: + memory: 512M + networks: + - huly_net + + rekoni: + image: hardcoreeng/rekoni-service:${HULY_VERSION} + environment: + - SECRET=${SECRET} + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 32M + restart: unless-stopped + logging: *logging-default + networks: + - huly_net + + transactor: + image: hardcoreeng/transactor:${HULY_VERSION} + environment: + - SERVER_PORT=3333 + - SERVER_SECRET=${SECRET} + - DB_URL=${CR_DB_URL} + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - FRONT_URL=http://localhost:8087 + - ACCOUNTS_URL=http://account:3000 + - FULLTEXT_URL=http://fulltext:4700 + - STATS_URL=http://stats:4900 + - LAST_NAME_FIRST=${LAST_NAME_FIRST:-true} + - QUEUE_CONFIG=redpanda:9092 + - NODE_OPTIONS=--max-old-space-size=768 + deploy: + resources: + limits: + memory: 1152M + reservations: + memory: 384M + restart: unless-stopped + logging: *logging-7d + networks: + - huly_net + + collaborator: + image: hardcoreeng/collaborator:${HULY_VERSION} + environment: + - COLLABORATOR_PORT=3078 + - SECRET=${SECRET} + - ACCOUNTS_URL=http://account:3000 + - STATS_URL=http://stats:4900 + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - NODE_OPTIONS=--max-old-space-size=192 + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 96M + restart: unless-stopped + logging: *logging-default + networks: + - huly_net + + account: + image: hardcoreeng/account:${HULY_VERSION} + environment: + - SERVER_PORT=3000 + - SERVER_SECRET=${SECRET} + - DB_URL=${CR_DB_URL} + - TRANSACTOR_URL=ws://transactor:3333;ws${SECURE:+s}://${HOST_ADDRESS}/_transactor + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - FRONT_URL=http${SECURE:+s}://${HOST_ADDRESS} + - STATS_URL=http${SECURE:+s}://${HOST_ADDRESS}/_stats + - MODEL_ENABLED=* + - ACCOUNTS_URL=http${SECURE:+s}://${HOST_ADDRESS}/_accounts + - ACCOUNT_PORT=3000 + - QUEUE_CONFIG=redpanda:9092 + - NODE_OPTIONS=--max-old-space-size=256 + deploy: + resources: + limits: + memory: 384M + reservations: + memory: 96M + restart: unless-stopped + logging: *logging-default + networks: + - huly_net + + workspace: + image: hardcoreeng/workspace:${HULY_VERSION} + environment: + - SERVER_SECRET=${SECRET} + - DB_URL=${CR_DB_URL} + - TRANSACTOR_URL=ws://transactor:3333;ws${SECURE:+s}://${HOST_ADDRESS}/_transactor + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - MODEL_ENABLED=* + - ACCOUNTS_URL=http://account:3000 + - STATS_URL=http://stats:4900 + - QUEUE_CONFIG=redpanda:9092 + - ACCOUNTS_DB_URL=${CR_DB_URL} + - INIT_REPO_DIR=/no-init-scripts + - NODE_OPTIONS=--max-old-space-size=384 + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 192M + restart: unless-stopped + logging: *logging-default + networks: + - huly_net + + front: + image: hardcoreeng/front:${HULY_VERSION} + environment: + - SERVER_PORT=8080 + - SERVER_SECRET=${SECRET} + - LOVE_ENDPOINT=http${SECURE:+s}://${HOST_ADDRESS}/_love + - ACCOUNTS_URL=http${SECURE:+s}://${HOST_ADDRESS}/_accounts + - ACCOUNTS_URL_INTERNAL=http://account:3000 + - REKONI_URL=http${SECURE:+s}://${HOST_ADDRESS}/_rekoni + - CALENDAR_URL=http${SECURE:+s}://${HOST_ADDRESS}/_calendar + - GMAIL_URL=http${SECURE:+s}://${HOST_ADDRESS}/_gmail + - TELEGRAM_URL=http${SECURE:+s}://${HOST_ADDRESS}/_telegram + - STATS_URL=http${SECURE:+s}://${HOST_ADDRESS}/_stats + - UPLOAD_URL=/files + - ELASTIC_URL=http://elastic:9200 + - COLLABORATOR_URL=ws${SECURE:+s}://${HOST_ADDRESS}/_collaborator + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - TITLE=${TITLE:-Huly Self Host} + - DEFAULT_LANGUAGE=${DEFAULT_LANGUAGE:-en} + - LAST_NAME_FIRST=${LAST_NAME_FIRST:-true} + - DESKTOP_UPDATES_CHANNEL=${DESKTOP_CHANNEL} + - DISABLED_FEATURES=auto-translate,mailboxes,signup,passwords,recover + - NODE_OPTIONS=--max-old-space-size=192 + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 96M + restart: unless-stopped + logging: *logging-default + networks: + - huly_net + + fulltext: + image: hardcoreeng/fulltext:${HULY_VERSION} + environment: + - SERVER_SECRET=${SECRET} + - DB_URL=${CR_DB_URL} + - FULLTEXT_DB_URL=http://elastic:9200 + - ELASTIC_INDEX_NAME=huly_storage_index + - STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin + - REKONI_URL=http://rekoni:4004 + - ACCOUNTS_URL=http://account:3000 + - STATS_URL=http://stats:4900 + - QUEUE_CONFIG=redpanda:9092 + - NODE_OPTIONS=--max-old-space-size=256 + deploy: + resources: + limits: + memory: 384M + reservations: + memory: 128M + restart: unless-stopped + logging: *logging-default + networks: + - huly_net + + stats: + image: hardcoreeng/stats:${HULY_VERSION} + environment: + - PORT=4900 + - SERVER_SECRET=${SECRET} + - NODE_OPTIONS=--max-old-space-size=128 + deploy: + resources: + limits: + memory: 192M + reservations: + memory: 64M + restart: unless-stopped + logging: *logging-5m + networks: + - huly_net + + kvs: + image: hardcoreeng/hulykvs:${HULY_VERSION} + depends_on: + cockroach: + condition: service_started + ports: + - 8094:8094 + environment: + - HULY_DB_CONNECTION=${CR_DB_URL} + - HULY_TOKEN_SECRET=${SECRET} + - NODE_OPTIONS=--max-old-space-size=96 + deploy: + resources: + limits: + memory: 128M + reservations: + memory: 32M + restart: unless-stopped + logging: *logging-default + networks: + - huly_net + +volumes: + elastic: + files: + cr_data: + cr_certs: + redpanda: + mongodb: + +networks: + huly_net: diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..e5e52f5b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,210 @@ +package config + +import ( + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + DefaultVersion = "v0.7.426" + DefaultDesktop = "0.7.426" + DefaultTitle = "Huly Self Host" + DefaultLanguage = "en" + DefaultDatabase = "defaultdb" + DefaultCRUser = "selfhost" + DefaultRedpandaUser = "superadmin" + DefaultComposeName = "huly_v7" + ConfigFileName = "huly_v7.conf" + ComposeFileName = "compose.yml" + NginxFileName = ".huly.nginx" + HulySecretFile = ".huly.secret" + CRSecretFile = ".cr.secret" + RedpandaSecretFile = ".rp.secret" +) + +type Mode int + +const ( + ModeInteractive Mode = iota + ModeQuick + ModeNonInteractive +) + +type Profile string + +const ( + ProfileMulti Profile = "multi" + ProfileSingle Profile = "single" +) + +type NetworkTopology string + +const ( + TopologyBuiltin NetworkTopology = "builtin" + TopologyReverse NetworkTopology = "reverse-proxy" +) + +// ExposeMode controls how services are published to the host when the +// in-stack nginx is dropped (reverse-proxy topology). +type ExposeMode string + +const ( + // ExposeNetwork keeps services on the docker network only — the proxy + // must join the `huly_net` network and reach them by hostname + // (e.g. http://front:8080). No host port bindings are created. + ExposeNetwork ExposeMode = "network" + // ExposeLocalhost binds each service port to 127.0.0.1 — the proxy + // runs on the host (system nginx / caddy / traefik) and connects + // via http://127.0.0.1:8080 etc. + ExposeLocalhost ExposeMode = "127.0.0.1" + // ExposeAll binds each service port to 0.0.0.0 — same as above but + // the host port is reachable from any interface. + ExposeAll ExposeMode = "0.0.0.0" +) + +type Config struct { + Mode Mode `yaml:"-"` + DryRun bool `yaml:"-"` + Profile Profile `yaml:"profile"` + Topology NetworkTopology `yaml:"topology"` + + HulyVersion string `yaml:"huly_version"` + DesktopChan string `yaml:"desktop_channel"` + + HostAddress string `yaml:"host_address"` + HTTPPort int `yaml:"http_port"` + HTTPBind string `yaml:"http_bind"` + Secure bool `yaml:"secure"` + + Title string `yaml:"title"` + DefaultLanguage string `yaml:"default_language"` + LastNameFirst bool `yaml:"last_name_first"` + + CRDatabase string `yaml:"cr_database"` + CRUsername string `yaml:"cr_username"` + RedpandaAdmin string `yaml:"redpanda_admin"` + + VolumeElasticPath string `yaml:"volume_elastic_path"` + VolumeFilesPath string `yaml:"volume_files_path"` + VolumeCRDataPath string `yaml:"volume_cr_data_path"` + VolumeCRCertsPath string `yaml:"volume_cr_certs_path"` + VolumeRedpanda string `yaml:"volume_redpanda_path"` + + ComposeName string `yaml:"compose_name"` + + // ExposeMode is only consulted when Topology == TopologyReverse. + ExposeMode ExposeMode `yaml:"expose_mode"` + + SkipPull bool `yaml:"-"` + SkipUp bool `yaml:"-"` +} + +func (c *Config) ApplyDefaults() { + if c.HulyVersion == "" { + c.HulyVersion = DefaultVersion + } + if c.DesktopChan == "" { + c.DesktopChan = strings.TrimPrefix(c.HulyVersion, "v") + } + if c.ComposeName == "" { + c.ComposeName = DefaultComposeName + } + if c.Title == "" { + c.Title = DefaultTitle + } + if c.DefaultLanguage == "" { + c.DefaultLanguage = DefaultLanguage + } + if c.CRDatabase == "" { + c.CRDatabase = DefaultDatabase + } + if c.CRUsername == "" { + c.CRUsername = DefaultCRUser + } + if c.RedpandaAdmin == "" { + c.RedpandaAdmin = DefaultRedpandaUser + } + if c.Profile == "" { + c.Profile = ProfileMulti + } + if c.Topology == "" { + c.Topology = TopologyBuiltin + } + if c.Topology == TopologyReverse && c.ExposeMode == "" { + c.ExposeMode = ExposeLocalhost + } + if !c.LastNameFirst { + c.LastNameFirst = true + } +} + +func (c *Config) Validate() error { + if c.HostAddress == "" { + return fmt.Errorf("host address is required") + } + if c.HTTPPort < 1 || c.HTTPPort > 65535 { + return fmt.Errorf("http port must be between 1 and 65535 (got %d)", c.HTTPPort) + } + if c.HulyVersion == "" { + return fmt.Errorf("huly version is required") + } + if c.Profile != ProfileMulti && c.Profile != ProfileSingle { + return fmt.Errorf("profile must be %q or %q", ProfileMulti, ProfileSingle) + } + if c.Topology != TopologyBuiltin && c.Topology != TopologyReverse { + return fmt.Errorf("topology must be %q or %q", TopologyBuiltin, TopologyReverse) + } + if c.Topology == TopologyReverse { + switch c.ExposeMode { + case ExposeNetwork, ExposeLocalhost, ExposeAll: + default: + return fmt.Errorf("reverse-proxy topology requires --expose-mode of network, 127.0.0.1, or 0.0.0.0") + } + } + return nil +} + +func (c *Config) IsLocal() bool { + h := strings.ToLower(strings.TrimSpace(c.HostAddress)) + if h == "" { + return true + } + if h == "localhost" || h == "127.0.0.1" || h == "::1" || strings.HasPrefix(h, "127.") { + return true + } + if ip := net.ParseIP(h); ip != nil && ip.IsLoopback() { + return true + } + return false +} + +func HulyDir() (string, error) { + if d := strings.TrimSpace(os.Getenv("HULY_SETUP_DIR")); d != "" { + abs, err := filepath.Abs(d) + if err != nil { + return "", err + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return "", err + } + return abs, nil + } + wd, err := os.Getwd() + if err != nil { + return "", err + } + return wd, nil +} + +func (c *Config) Path(name string) string { + dir, _ := HulyDir() + return filepath.Join(dir, name) +} + +func (c *Config) PortString() string { + return strconv.Itoa(c.HTTPPort) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..1b5d036c --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,137 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestApplyDefaults(t *testing.T) { + c := Config{} + c.ApplyDefaults() + if c.HulyVersion != DefaultVersion { + t.Fatalf("expected default version %q, got %q", DefaultVersion, c.HulyVersion) + } + if c.DesktopChan != DefaultDesktop { + t.Fatalf("expected default desktop channel %q, got %q", DefaultDesktop, c.DesktopChan) + } + if !c.LastNameFirst { + t.Fatal("LastNameFirst should default to true") + } + if c.Profile != ProfileMulti { + t.Fatalf("expected default profile multi, got %q", c.Profile) + } + if c.Topology != TopologyBuiltin { + t.Fatalf("expected default topology builtin, got %q", c.Topology) + } +} + +func TestValidate(t *testing.T) { + c := Config{} + c.ApplyDefaults() + if err := c.Validate(); err == nil { + t.Fatal("expected validation error for empty host") + } + c.HostAddress = "huly.example.com" + c.HTTPPort = 80 + if err := c.Validate(); err != nil { + t.Fatalf("expected ok, got %v", err) + } + c.HTTPPort = 70000 + if err := c.Validate(); err == nil { + t.Fatal("expected validation error for bad port") + } + c.HTTPPort = 80 + c.Profile = "weird" + if err := c.Validate(); err == nil { + t.Fatal("expected validation error for bad profile") + } + c.Profile = ProfileMulti + c.Topology = "weird" + if err := c.Validate(); err == nil { + t.Fatal("expected validation error for bad topology") + } +} + +func TestIsLocal(t *testing.T) { + cases := map[string]bool{ + "localhost": true, + "127.0.0.1": true, + "127.0.0.5": true, + "::1": true, + "huly.local": false, + "": true, + } + for host, want := range cases { + c := Config{HostAddress: host} + if got := c.IsLocal(); got != want { + t.Errorf("IsLocal(%q) = %v, want %v", host, got, want) + } + } +} + +func TestLoadSave(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "huly_v7.conf") + want := Config{ + HulyVersion: "v0.7.426", + DesktopChan: "0.7.426", + ComposeName: "huly_v7", + Profile: ProfileSingle, + Topology: TopologyReverse, + HostAddress: "huly.example.com", + HTTPPort: 443, + Secure: true, + Title: "Acme", + DefaultLanguage: "en", + LastNameFirst: true, + CRDatabase: "defaultdb", + CRUsername: "selfhost", + RedpandaAdmin: "superadmin", + VolumeElasticPath: "/srv/elastic", + } + if err := Save(want, path); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got.HulyVersion != want.HulyVersion || + got.Profile != want.Profile || + got.Topology != want.Topology || + got.HostAddress != want.HostAddress || + got.HTTPPort != want.HTTPPort || + got.Secure != want.Secure || + got.VolumeElasticPath != want.VolumeElasticPath || + got.ComposeName != want.ComposeName { + t.Fatalf("round trip mismatch:\n want=%+v\n got =%+v", want, got) + } +} + +func TestLoadFromExistingExample(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "huly_v7.conf") + body := []byte(`HULY_VERSION=v0.6.501 +HOST_ADDRESS=localhost:8080 +SECURE= +HTTP_PORT=8080 +HTTP_BIND= +TITLE=Huly Self Host +LAST_NAME_FIRST=true +CR_USER_PASSWORD=foo +`) + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatal(err) + } + c, err := Load(path) + if err != nil { + t.Fatal(err) + } + if c.HulyVersion != "v0.6.501" || c.HostAddress != "localhost:8080" || c.HTTPPort != 8080 { + t.Fatalf("unexpected: %+v", c) + } + if c.Secure { + t.Fatal("SECURE= should not be true") + } +} diff --git a/internal/config/loadsave.go b/internal/config/loadsave.go new file mode 100644 index 00000000..70a79743 --- /dev/null +++ b/internal/config/loadsave.go @@ -0,0 +1,193 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Load parses a previously-written huly_v7.conf. Unknown keys and blank or +// comment lines are silently skipped. ${VAR:-default} syntax is resolved using +// process env where available. +func Load(path string) (Config, error) { + f, err := os.Open(path) + if err != nil { + return Config{}, err + } + defer f.Close() + + c := Config{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + // envsubst syntax: ${VAR:-default} or ${VAR} + val = resolve(val) + + switch key { + case "HULY_VERSION": + c.HulyVersion = val + case "DESKTOP_CHANNEL": + c.DesktopChan = val + case "DOCKER_NAME": + c.ComposeName = val + case "HOST_ADDRESS": + c.HostAddress = val + case "SECURE": + c.Secure = val == "true" + case "HTTP_PORT": + if v, err := atoiSafe(val); err == nil { + c.HTTPPort = v + } + case "HTTP_BIND": + c.HTTPBind = val + case "TITLE": + c.Title = val + case "DEFAULT_LANGUAGE": + c.DefaultLanguage = val + case "LAST_NAME_FIRST": + c.LastNameFirst = val == "true" + case "CR_DATABASE": + c.CRDatabase = val + case "CR_USERNAME": + c.CRUsername = val + case "REDPANDA_ADMIN_USER": + c.RedpandaAdmin = val + case "VOLUME_ELASTIC_PATH": + c.VolumeElasticPath = val + case "VOLUME_FILES_PATH": + c.VolumeFilesPath = val + case "VOLUME_CR_DATA_PATH": + c.VolumeCRDataPath = val + case "VOLUME_CR_CERTS_PATH": + c.VolumeCRCertsPath = val + case "VOLUME_REDPANDA_PATH": + c.VolumeRedpanda = val + case "PROFILE": + c.Profile = Profile(val) + case "TOPOLOGY": + c.Topology = NetworkTopology(val) + case "EXPOSE_MODE": + c.ExposeMode = ExposeMode(val) + } + } + if err := scanner.Err(); err != nil { + return c, err + } + return c, nil +} + +// resolve strips shell-style ${VAR:-default} wrappers. +func resolve(s string) string { + if !strings.Contains(s, "${") { + return s + } + out := s + for { + open := strings.Index(out, "${") + if open < 0 { + break + } + close := strings.Index(out[open:], "}") + if close < 0 { + break + } + close += open + expr := out[open+2 : close] + name := expr + def := "" + if i := strings.Index(expr, ":-"); i >= 0 { + name = expr[:i] + def = expr[i+2:] + } + // honour env override if present, else default, else empty + if v, ok := os.LookupEnv(name); ok { + out = out[:open] + v + out[close+1:] + } else if def != "" { + out = out[:open] + def + out[close+1:] + } else { + out = out[:open] + out[close+1:] + } + } + return out +} + +func atoiSafe(s string) (int, error) { + var v int + _, err := fmt.Sscanf(s, "%d", &v) + return v, err +} + +// Save writes the config to disk in a stable shell-sourceable format that the +// docker compose stack expects. Existing files are overwritten; callers should +// have already taken a backup if they care. +func Save(c Config, path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + w := bufio.NewWriter(tmp) + fmt.Fprintln(w, "# Managed by huly-setup. Re-run to update.") + fmt.Fprintln(w, "# Regenerate with: huly-setup --apply") + fmt.Fprintln(w) + fmt.Fprintf(w, "HULY_VERSION=%s\n", c.HulyVersion) + fmt.Fprintf(w, "DESKTOP_CHANNEL=%s\n", c.DesktopChan) + fmt.Fprintf(w, "DOCKER_NAME=%s\n", c.ComposeName) + fmt.Fprintf(w, "PROFILE=%s\n", c.Profile) + fmt.Fprintf(w, "TOPOLOGY=%s\n", c.Topology) + if c.Topology == TopologyReverse { + fmt.Fprintf(w, "EXPOSE_MODE=%s\n", c.ExposeMode) + } + fmt.Fprintln(w) + fmt.Fprintf(w, "HOST_ADDRESS=%s\n", c.HostAddress) + if c.Secure { + fmt.Fprintln(w, "SECURE=true") + } else { + fmt.Fprintln(w, "SECURE=") + } + fmt.Fprintf(w, "HTTP_PORT=%d\n", c.HTTPPort) + fmt.Fprintf(w, "HTTP_BIND=%s\n", c.HTTPBind) + fmt.Fprintln(w) + fmt.Fprintf(w, "TITLE=%s\n", c.Title) + fmt.Fprintf(w, "DEFAULT_LANGUAGE=%s\n", c.DefaultLanguage) + if c.LastNameFirst { + fmt.Fprintln(w, "LAST_NAME_FIRST=true") + } else { + fmt.Fprintln(w, "LAST_NAME_FIRST=false") + } + fmt.Fprintln(w) + fmt.Fprintf(w, "CR_DATABASE=%s\n", c.CRDatabase) + fmt.Fprintf(w, "CR_USERNAME=%s\n", c.CRUsername) + fmt.Fprintf(w, "REDPANDA_ADMIN_USER=%s\n", c.RedpandaAdmin) + fmt.Fprintln(w) + fmt.Fprintf(w, "VOLUME_ELASTIC_PATH=%s\n", c.VolumeElasticPath) + fmt.Fprintf(w, "VOLUME_FILES_PATH=%s\n", c.VolumeFilesPath) + fmt.Fprintf(w, "VOLUME_CR_DATA_PATH=%s\n", c.VolumeCRDataPath) + fmt.Fprintf(w, "VOLUME_CR_CERTS_PATH=%s\n", c.VolumeCRCertsPath) + fmt.Fprintf(w, "VOLUME_REDPANDA_PATH=%s\n", c.VolumeRedpanda) + fmt.Fprintln(w) + + if err := w.Flush(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go new file mode 100644 index 00000000..a01483cd --- /dev/null +++ b/internal/docker/docker.go @@ -0,0 +1,208 @@ +// Package docker wraps the docker compose CLI for the setup tool. All exec +// helpers are side-effect aware: when wrapped in a DryRunInvoker, the actual +// command is logged but not executed. +package docker + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +type Invoker interface { + Info(ctx context.Context) (string, error) + Pull(ctx context.Context, projectDir string) error + Up(ctx context.Context, projectDir string, detach bool) error + Down(ctx context.Context, projectDir string) error + Ps(ctx context.Context, projectDir string) (string, error) + Command(ctx context.Context, projectDir string, args ...string) error + CommandOutput(ctx context.Context, projectDir string, args ...string) (string, error) +} + +type CLIInvoker struct { + Env []string + Stdout io.Writer + Stderr io.Writer +} + +func (c *CLIInvoker) composeBin() string { + if runtime.GOOS == "windows" { + return "docker.exe" + } + return "docker" +} + +func (c *CLIInvoker) run(ctx context.Context, projectDir string, capture bool, args ...string) error { + if projectDir != "" { + args = insertProjectDir(args, projectDir) + } + cmd := exec.CommandContext(ctx, c.composeBin(), args...) + if projectDir != "" { + cmd.Dir = projectDir + } + cmd.Env = append(os.Environ(), c.Env...) + if c.Stdout != nil { + cmd.Stdout = c.Stdout + } else { + cmd.Stdout = os.Stdout + } + if c.Stderr != nil { + cmd.Stderr = c.Stderr + } else { + cmd.Stderr = os.Stderr + } + if capture { + cmd.Stdout = nil + cmd.Stderr = nil + out, err := cmd.CombinedOutput() + if c.Stdout != nil { + fmt.Fprintln(c.Stdout, string(out)) + } + return err + } + return cmd.Run() +} + +func (c *CLIInvoker) runCapture(ctx context.Context, projectDir string, args ...string) (string, error) { + if projectDir != "" { + args = insertProjectDir(args, projectDir) + } + cmd := exec.CommandContext(ctx, c.composeBin(), args...) + if projectDir != "" { + cmd.Dir = projectDir + } + cmd.Env = append(os.Environ(), c.Env...) + out, err := cmd.CombinedOutput() + return string(out), err +} + +// insertProjectDir inserts `--project-directory ` after the first +// `compose` positional so that it ends up as a `docker compose` flag rather +// than a top-level `docker` flag (the latter is unsupported). +func insertProjectDir(args []string, dir string) []string { + out := make([]string, 0, len(args)+2) + out = append(out, args...) + insertAt := 0 + for i, a := range args { + if a == "compose" { + insertAt = i + 1 + break + } + } + out = append(out, "") + copy(out[insertAt+2:], out[insertAt:]) + out[insertAt] = "--project-directory" + out[insertAt+1] = dir + return out +} + +func (c *CLIInvoker) Info(ctx context.Context) (string, error) { + return c.runCapture(ctx, "", "info", "--format", "{{.ServerVersion}}") +} + +func (c *CLIInvoker) Pull(ctx context.Context, projectDir string) error { + return c.run(ctx, projectDir, false, "compose", "pull") +} + +func (c *CLIInvoker) Up(ctx context.Context, projectDir string, detach bool) error { + args := []string{"compose", "up"} + if detach { + args = append(args, "-d") + } + return c.run(ctx, projectDir, false, args...) +} + +func (c *CLIInvoker) Down(ctx context.Context, projectDir string) error { + return c.run(ctx, projectDir, false, "compose", "down") +} + +func (c *CLIInvoker) Ps(ctx context.Context, projectDir string) (string, error) { + return c.runCapture(ctx, projectDir, "compose", "ps", "--format", "json") +} + +func (c *CLIInvoker) Command(ctx context.Context, projectDir string, args ...string) error { + return c.run(ctx, projectDir, false, args...) +} + +func (c *CLIInvoker) CommandOutput(ctx context.Context, projectDir string, args ...string) (string, error) { + return c.runCapture(ctx, projectDir, args...) +} + +// DryRunInvoker wraps another Invoker, logging every call instead of executing +// it. Useful for `huly-setup --dry-run` and tests. +type DryRunInvoker struct { + Inner Invoker + Logger func(string) +} + +func (d *DryRunInvoker) log(s string) { + if d.Logger != nil { + d.Logger(s) + return + } + fmt.Fprintln(os.Stdout, s) +} + +func (d *DryRunInvoker) Info(ctx context.Context) (string, error) { + d.log("[dry-run] docker info") + if d.Inner != nil { + return d.Inner.Info(ctx) + } + return "dry-run", nil +} + +func (d *DryRunInvoker) Pull(ctx context.Context, dir string) error { + d.log("[dry-run] docker compose pull (cwd=" + filepath.Clean(dir) + ")") + if d.Inner != nil { + return d.Inner.Pull(ctx, dir) + } + return nil +} + +func (d *DryRunInvoker) Up(ctx context.Context, dir string, detach bool) error { + flag := "" + if detach { + flag = " -d" + } + d.log(fmt.Sprintf("[dry-run] docker compose up%s (cwd=%s)", flag, filepath.Clean(dir))) + if d.Inner != nil { + return d.Inner.Up(ctx, dir, detach) + } + return nil +} + +func (d *DryRunInvoker) Down(ctx context.Context, dir string) error { + d.log("[dry-run] docker compose down (cwd=" + filepath.Clean(dir) + ")") + if d.Inner != nil { + return d.Inner.Down(ctx, dir) + } + return nil +} + +func (d *DryRunInvoker) Ps(ctx context.Context, dir string) (string, error) { + d.log("[dry-run] docker compose ps (cwd=" + filepath.Clean(dir) + ")") + if d.Inner != nil { + return d.Inner.Ps(ctx, dir) + } + return "[]", nil +} + +func (d *DryRunInvoker) Command(ctx context.Context, dir string, args ...string) error { + d.log(fmt.Sprintf("[dry-run] docker %v (cwd=%s)", args, filepath.Clean(dir))) + if d.Inner != nil { + return d.Inner.Command(ctx, dir, args...) + } + return nil +} + +func (d *DryRunInvoker) CommandOutput(ctx context.Context, dir string, args ...string) (string, error) { + d.log(fmt.Sprintf("[dry-run] docker %v (cwd=%s)", args, filepath.Clean(dir))) + if d.Inner != nil { + return d.Inner.CommandOutput(ctx, dir, args...) + } + return "", nil +} diff --git a/internal/docker/docker_test.go b/internal/docker/docker_test.go new file mode 100644 index 00000000..aea79741 --- /dev/null +++ b/internal/docker/docker_test.go @@ -0,0 +1,73 @@ +package docker + +import ( + "context" + "errors" + "testing" +) + +type fakeInvoker struct { + calls []string + err error +} + +func (f *fakeInvoker) Info(ctx context.Context) (string, error) { + f.calls = append(f.calls, "info") + return "v1.0", f.err +} +func (f *fakeInvoker) Pull(ctx context.Context, dir string) error { + f.calls = append(f.calls, "pull:"+dir) + return f.err +} +func (f *fakeInvoker) Up(ctx context.Context, dir string, detach bool) error { + f.calls = append(f.calls, "up:"+dir) + return f.err +} +func (f *fakeInvoker) Down(ctx context.Context, dir string) error { + f.calls = append(f.calls, "down:"+dir) + return f.err +} +func (f *fakeInvoker) Ps(ctx context.Context, dir string) (string, error) { + f.calls = append(f.calls, "ps:"+dir) + return "[]", f.err +} +func (f *fakeInvoker) Command(ctx context.Context, dir string, args ...string) error { + f.calls = append(f.calls, "cmd:"+dir) + return f.err +} +func (f *fakeInvoker) CommandOutput(ctx context.Context, dir string, args ...string) (string, error) { + f.calls = append(f.calls, "cmdout:"+dir) + return "", f.err +} + +func TestDryRunInvokerDoesNotCallInner(t *testing.T) { + fake := &fakeInvoker{} + dr := &DryRunInvoker{Inner: fake} + dr.Info(context.Background()) + dr.Up(context.Background(), "/tmp", true) + dr.Pull(context.Background(), "/tmp") + if len(fake.calls) != 3 { + t.Fatalf("expected 3 calls, got %v", fake.calls) + } +} + +func TestDryRunInvokerNilInner(t *testing.T) { + dr := &DryRunInvoker{} + if err := dr.Up(context.Background(), "/tmp", true); err != nil { + t.Fatal(err) + } + if err := dr.Down(context.Background(), "/tmp"); err != nil { + t.Fatal(err) + } + if _, err := dr.Info(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestDryRunInvokerPropagatesError(t *testing.T) { + fake := &fakeInvoker{err: errors.New("boom")} + dr := &DryRunInvoker{Inner: fake} + if err := dr.Pull(context.Background(), "/tmp"); err == nil { + t.Fatal("expected propagated error") + } +} diff --git a/internal/envconf/envconf.go b/internal/envconf/envconf.go new file mode 100644 index 00000000..09365ef9 --- /dev/null +++ b/internal/envconf/envconf.go @@ -0,0 +1,119 @@ +// Package envconf renders the .env (huly_v7.conf) file consumed by +// docker-compose and the embedded Huly services. +package envconf + +import ( + "bytes" + "os" + "path/filepath" + "text/template" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +const tmpl = `HULY_VERSION={{.HULY_VERSION}} +DESKTOP_CHANNEL={{.DESKTOP_CHANNEL}} +DOCKER_NAME={{.DOCKER_NAME}} +PROFILE={{.PROFILE}} +TOPOLOGY={{.TOPOLOGY}} +{{- if eq .TOPOLOGY "reverse-proxy"}} +EXPOSE_MODE={{.EXPOSE_MODE}}{{end}} + +HOST_ADDRESS={{.HOST_ADDRESS}} +SECURE={{.SECURE}} +HTTP_PORT={{.HTTP_PORT}} +HTTP_BIND={{.HTTP_BIND}} + +TITLE={{.TITLE}} +DEFAULT_LANGUAGE={{.DEFAULT_LANGUAGE}} +LAST_NAME_FIRST={{.LAST_NAME_FIRST}} + +CR_DATABASE={{.CR_DATABASE}} +CR_USERNAME={{.CR_USERNAME}} +CR_USER_PASSWORD={{.CR_USER_PASSWORD}} +CR_DB_URL=postgres://{{.CR_USERNAME}}:{{.CR_USER_PASSWORD}}@cockroach:26257/{{.CR_DATABASE}} + +REDPANDA_ADMIN_USER={{.REDPANDA_ADMIN_USER}} +REDPANDA_ADMIN_PWD={{.REDPANDA_ADMIN_PWD}} + +# Volume host-path overrides (empty = docker named volumes) +VOLUME_ELASTIC_PATH={{.VOLUME_ELASTIC_PATH}} +VOLUME_FILES_PATH={{.VOLUME_FILES_PATH}} +VOLUME_CR_DATA_PATH={{.VOLUME_CR_DATA_PATH}} +VOLUME_CR_CERTS_PATH={{.VOLUME_CR_CERTS_PATH}} +VOLUME_REDPANDA_PATH={{.VOLUME_REDPANDA_PATH}} + +# Auto-generated. Regenerate by running ` + "`huly-setup --rotate-secrets`" + `. +SECRET={{.SECRET}} +` + +func Render(c config.Config, hulySecret, crSecret, rpSecret string) (string, error) { + if err := c.Validate(); err != nil { + return "", err + } + secure := "" + if c.Secure { + secure = "true" + } + lnf := "true" + if !c.LastNameFirst { + lnf = "false" + } + t, err := template.New("env").Parse(tmpl) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := t.Execute(&buf, map[string]any{ + "HULY_VERSION": c.HulyVersion, + "DESKTOP_CHANNEL": c.DesktopChan, + "DOCKER_NAME": c.ComposeName, + "PROFILE": c.Profile, + "TOPOLOGY": c.Topology, + "EXPOSE_MODE": string(c.ExposeMode), + "HOST_ADDRESS": c.HostAddress, + "SECURE": secure, + "HTTP_PORT": c.PortString(), + "HTTP_BIND": c.HTTPBind, + "TITLE": c.Title, + "DEFAULT_LANGUAGE": c.DefaultLanguage, + "LAST_NAME_FIRST": lnf, + "CR_DATABASE": c.CRDatabase, + "CR_USERNAME": c.CRUsername, + "CR_USER_PASSWORD": crSecret, + "REDPANDA_ADMIN_USER": c.RedpandaAdmin, + "REDPANDA_ADMIN_PWD": rpSecret, + "VOLUME_ELASTIC_PATH": c.VolumeElasticPath, + "VOLUME_FILES_PATH": c.VolumeFilesPath, + "VOLUME_CR_DATA_PATH": c.VolumeCRDataPath, + "VOLUME_CR_CERTS_PATH": c.VolumeCRCertsPath, + "VOLUME_REDPANDA_PATH": c.VolumeRedpanda, + "SECRET": hulySecret, + }); err != nil { + return "", err + } + return buf.String(), nil +} + +func Save(c config.Config, hulySecret, crSecret, rpSecret, path string) error { + rendered, err := Render(c, hulySecret, crSecret, rpSecret) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.WriteString(rendered); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/internal/envconf/envconf_test.go b/internal/envconf/envconf_test.go new file mode 100644 index 00000000..673e9bf5 --- /dev/null +++ b/internal/envconf/envconf_test.go @@ -0,0 +1,56 @@ +package envconf + +import ( + "strings" + "testing" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +func baseCfg() config.Config { + c := config.Config{ + HostAddress: "huly.example.com", + HTTPPort: 443, + Secure: true, + } + c.ApplyDefaults() + return c +} + +func TestRenderIncludesSecrets(t *testing.T) { + out, err := Render(baseCfg(), "huly-hex", "cr-hex", "rp-hex") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "HULY_VERSION=v0.7.426", + "HOST_ADDRESS=huly.example.com", + "HTTP_PORT=443", + "SECURE=true", + "PROFILE=multi", + "TOPOLOGY=builtin", + "SECRET=huly-hex", + "CR_USER_PASSWORD=cr-hex", + "REDPANDA_ADMIN_PWD=rp-hex", + "CR_DB_URL=postgres://selfhost:cr-hex@cockroach:26257/defaultdb", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } +} + +func TestRenderInsecure(t *testing.T) { + c := baseCfg() + c.HTTPPort = 80 + c.Secure = false + out, err := Render(c, "x", "y", "z") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "SECURE=\n") && !strings.Contains(out, "SECURE= ") { + if !strings.Contains(out, "SECURE=") { + t.Fatal("missing SECURE entry") + } + } +} diff --git a/internal/nginx/container.conf b/internal/nginx/container.conf new file mode 100644 index 00000000..cf18dc3f --- /dev/null +++ b/internal/nginx/container.conf @@ -0,0 +1,89 @@ +server { + listen 80; + server_name ${HOST_ADDRESS}; + client_max_body_size 100M; + + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://front:8080; + } + + location /_accounts { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + rewrite ^/_accounts(/.*)$ $1 break; + proxy_pass http://account:3000/; + } + + location /_collaborator { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + rewrite ^/_collaborator(/.*)$ $1 break; + proxy_pass http://collaborator:3078/; + } + + location /_transactor { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + rewrite ^/_transactor(/.*)$ $1 break; + proxy_pass http://transactor:3333/; + } + + location ~ ^/eyJ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://transactor:3333; + } + + location /_rekoni { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + rewrite ^/_rekoni(/.*)$ $1 break; + proxy_pass http://rekoni:4004/; + } + + location /_stats { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + rewrite ^/_stats(/.*)$ $1 break; + proxy_pass http://stats:4900/; + } + + location /files/ { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://minio:9000/; + } +} diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go new file mode 100644 index 00000000..a40e2ee8 --- /dev/null +++ b/internal/nginx/nginx.go @@ -0,0 +1,67 @@ +// Package nginx renders the .huly.nginx config used by the in-stack nginx +// container, or a paste-ready reverse-proxy snippet for users who already +// have nginx / caddy / traefik in front. +package nginx + +import ( + _ "embed" + "fmt" + "strings" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +//go:embed container.conf +var containerConf string + +//go:embed upstream.conf +var upstreamConf string + +func Render(c config.Config) (string, error) { + if err := c.Validate(); err != nil { + return "", err + } + body := containerConf + host := c.HostAddress + if c.IsLocal() { + host = "localhost" + } + return strings.ReplaceAll(body, "${HOST_ADDRESS}", host), nil +} + +// ReverseProxySnippet returns a paste-ready nginx server block that proxies +// to the Huly compose stack. When ExposeMode is localhost or 0.0.0.0 the +// upstreams point at the host bind IP. When ExposeMode is network the +// upstreams point at the docker service hostnames (the proxy must run in +// the huly_net docker network). +func ReverseProxySnippet(c config.Config) (string, error) { + if err := c.Validate(); err != nil { + return "", err + } + listen := fmt.Sprintf("%d", c.HTTPPort) + if c.Secure { + listen = "443 ssl" + } + host := "huly_" // placeholder; replaced below + body := strings.ReplaceAll(upstreamConf, "${LISTEN_DIRECTIVE}", listen) + switch c.ExposeMode { + case config.ExposeLocalhost: + body = strings.ReplaceAll(body, "server front:8080", "server 127.0.0.1:8080") + body = strings.ReplaceAll(body, "server account:3000", "server 127.0.0.1:3000") + body = strings.ReplaceAll(body, "server transactor:3333", "server 127.0.0.1:3333") + body = strings.ReplaceAll(body, "server collaborator:3078", "server 127.0.0.1:3078") + body = strings.ReplaceAll(body, "server rekoni:4004", "server 127.0.0.1:4004") + body = strings.ReplaceAll(body, "server stats:4900", "server 127.0.0.1:4900") + body = strings.ReplaceAll(body, "server minio:9000", "server 127.0.0.1:9000") + case config.ExposeAll: + body = strings.ReplaceAll(body, "server front:8080", "server 0.0.0.0:8080") + body = strings.ReplaceAll(body, "server account:3000", "server 0.0.0.0:3000") + body = strings.ReplaceAll(body, "server transactor:3333", "server 0.0.0.0:3333") + body = strings.ReplaceAll(body, "server collaborator:3078", "server 0.0.0.0:3078") + body = strings.ReplaceAll(body, "server rekoni:4004", "server 0.0.0.0:4004") + body = strings.ReplaceAll(body, "server stats:4900", "server 0.0.0.0:4900") + body = strings.ReplaceAll(body, "server minio:9000", "server 0.0.0.0:9000") + } + _ = host + return body, nil +} diff --git a/internal/nginx/nginx_test.go b/internal/nginx/nginx_test.go new file mode 100644 index 00000000..44a953a4 --- /dev/null +++ b/internal/nginx/nginx_test.go @@ -0,0 +1,65 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +func TestRenderBuiltin(t *testing.T) { + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443} + c.ApplyDefaults() + out, err := Render(c) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "server_name huly.example.com") { + t.Fatalf("expected server_name substituted:\n%s", out) + } + if !strings.Contains(out, "proxy_pass http://front:8080") { + t.Fatalf("expected proxy_pass:\n%s", out) + } +} + +func TestRenderReverseProxySnippet(t *testing.T) { + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true} + c.ApplyDefaults() + c.Topology = config.TopologyReverse + c.ExposeMode = config.ExposeNetwork + out, err := ReverseProxySnippet(c) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "upstream huly_front", + "server front:8080", + "listen 443 ssl", + "proxy_pass http://huly_transactor", + } { + if !strings.Contains(out, want) { + t.Errorf("snippet missing %q", want) + } + } +} + +func TestRenderReverseProxySnippetLocalhost(t *testing.T) { + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true} + c.ApplyDefaults() + c.Topology = config.TopologyReverse + c.ExposeMode = config.ExposeLocalhost + out, err := ReverseProxySnippet(c) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "server 127.0.0.1:8080", + "server 127.0.0.1:3000", + "server 127.0.0.1:3333", + "server 127.0.0.1:9000", + } { + if !strings.Contains(out, want) { + t.Errorf("localhost snippet missing %q", want) + } + } +} diff --git a/internal/nginx/upstream.conf b/internal/nginx/upstream.conf new file mode 100644 index 00000000..d4678009 --- /dev/null +++ b/internal/nginx/upstream.conf @@ -0,0 +1,104 @@ +# Reverse-proxy snippet - paste into your existing nginx server block, or use +# as inspiration for caddy/traefik. The Huly docker stack is expected to be on +# a docker network accessible to your proxy; on the standard `huly_net` bridge +# the service hostnames below resolve directly. +# +# Make sure to: +# 1. Replace `your.host.example` with your real public hostname. +# 2. Add ssl_certificate + ssl_certificate_key directives if SECURE=true. +# 3. Allow websocket Upgrade / Connection headers (see the _transactor and +# _collaborator locations). +# 4. Allow large bodies (client_max_body_size 100M;) for file uploads. + +upstream huly_front { server front:8080; } +upstream huly_account { server account:3000; } +upstream huly_transactor { server transactor:3333; } +upstream huly_collaborator{ server collaborator:3078; } +upstream huly_rekoni { server rekoni:4004; } +upstream huly_stats { server stats:4900; } +upstream huly_files { server minio:9000; } + +server { + listen ${LISTEN_DIRECTIVE}; + server_name your.host.example; + client_max_body_size 100M; + + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://huly_front; + } + + location /_accounts/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://huly_account/; + } + + location /_collaborator/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://huly_collaborator/; + } + + location /_transactor/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://huly_transactor/; + } + + location ~ ^/eyJ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://huly_transactor; + } + + location /_rekoni/ { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://huly_rekoni/; + } + + location /_stats/ { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://huly_stats/; + } + + location /files/ { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://huly_files/; + } +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 00000000..ab8f6aa9 --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,81 @@ +// Package profile describes the trade-offs between multi-tenant and +// single-tenant deployment profiles. The descriptions are surfaced in both +// the TUI and the --help text. +package profile + +import ( + "fmt" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +// Description returns a multi-line explanation of the profile. +func Description(p config.Profile) string { + switch p { + case config.ProfileSingle: + return single + case config.ProfileMulti: + return multi + default: + return fmt.Sprintf("unknown profile %q", p) + } +} + +// Short returns a single-line summary. +func Short(p config.Profile) string { + switch p { + case config.ProfileSingle: + return "Single host / single user — tuned memory & logging caps, ~4 GB RAM at idle." + case config.ProfileMulti: + return "Multi-tenant / SaaS — unbounded resources, upstream-equivalent defaults." + default: + return string(p) + } +} + +const multi = `Multi-tenant / SaaS profile + + ▸ What this means: + Designed for hosting many concurrent users. Services are NOT memory-capped + and the default logging is unbounded. + + ▸ When to pick this: + • You are running Huly for a team, an organization, or as a paid service. + • You want upstream-equivalent behaviour and don't mind the resource cost. + • You plan to scale horizontally or have already tuned the compose file. + + ▸ What you give up: + • Larger disk usage from logs (no per-service rotation caps). + • No OOM protection — a single buggy container can starve the host. + + ▸ What you get: + • Predictable performance characteristics for many simultaneous users. + • Compatibility with the upstream huly-selfhost reference setup. + +→ Press Enter for this profile, or use --single-tenant / --multi-tenant.` + +const single = `Single-tenant / single-host profile (RECOMMENDED for self-host) + + ▸ What this means: + Tuned for ONE user running the entire stack on a small VPS. Every service + has a memory limit and the NODE heap is capped at ~75% of that. Logs are + rotated per-service so a chatty container can't fill the disk. + + ▸ Memory footprint at idle: + ~4 GB RAM total. Heavy load can push it to ~6 GB. + + ▸ Disabled for self-host: + auto-translate, mailboxes, signup, passwords, recover (you don't need a + password-reset flow when there's a single owner). + + ▸ Also enables: + • init-repo skipping (INIT_REPO_DIR=/no-init-scripts) so new workspaces + aren't seeded with example content. + • Per-service JSON log rotation with measured caps. + • Smaller Elastic heap (512m – 768m) — plenty for a single user's index. + + ▸ When NOT to pick this: + • You serve more than a handful of concurrent users. + • You're benchmarking Huly's performance. + +→ Press Enter for this profile, or use --single-tenant / --multi-tenant.` diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go new file mode 100644 index 00000000..9ba48676 --- /dev/null +++ b/internal/profile/profile_test.go @@ -0,0 +1,26 @@ +package profile + +import ( + "testing" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +func TestShort(t *testing.T) { + got := Short(config.ProfileSingle) + if got == "" { + t.Fatal("empty short description") + } + if got == Short(config.ProfileMulti) { + t.Fatal("short descriptions should differ") + } +} + +func TestDescriptionHasKeySections(t *testing.T) { + if d := Description(config.ProfileSingle); d == "" { + t.Fatal("empty single description") + } + if d := Description(config.ProfileMulti); d == "" { + t.Fatal("empty multi description") + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 00000000..fa1a6850 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,300 @@ +// Package runner orchestrates the end-to-end setup: load previous config, ask +// the user (or use flags), validate, render artifacts (env, compose, nginx), +// and optionally invoke docker compose. +package runner + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/compose" + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/docker" + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/envconf" + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/nginx" + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/secrets" +) + +// Options controls the run. The TUI populates it incrementally; CLI flags +// populate it up-front for --non-interactive runs. +type Options struct { + // Existing path to override config load (defaults to huly_v7.conf in cwd). + ConfigPath string + // Output path for the generated env file. Defaults to /huly_v7.conf. + EnvPath string + // Output path for the generated compose.yml. + ComposePath string + // Output path for the generated .huly.nginx (in-container nginx config). + NginxPath string + // Optional output path for the reverse-proxy snippet. + SnippetPath string + // Where the secret files live (defaults to /). + SecretDir string + + // Rotate any existing secrets (otherwise reuse). + RotateSecrets bool + + // Pull + Up - the standard "apply" path. + Pull bool + Up bool + + // Skip applying - just render artifacts. + OnlyRender bool +} + +// Result summarises what the runner did. +type Result struct { + Config config.Config + EnvPath string + ComposePath string + NginxPath string + SnippetPath string + ComposeRendered string + EnvRendered string + NginxRendered string + SnippetRendered string + HulySecret string + CRSecret string + RedpandaSecret string + StartTime time.Time + Duration time.Duration + DockerActions []string +} + +// Run executes the setup. The cfg passed in has already had defaults applied +// and any TUI/flag overrides; runner handles persistence + execution only. +func Run(ctx context.Context, cfg config.Config, opts Options, inv docker.Invoker, out io.Writer) (*Result, error) { + if out == nil { + out = os.Stdout + } + if err := cfg.Validate(); err != nil { + return nil, err + } + + dir, err := config.HulyDir() + if err != nil { + return nil, err + } + if opts.ConfigPath == "" { + opts.ConfigPath = filepath.Join(dir, config.ConfigFileName) + } + if opts.EnvPath == "" { + opts.EnvPath = opts.ConfigPath + } + if opts.ComposePath == "" { + opts.ComposePath = filepath.Join(dir, config.ComposeFileName) + } + if opts.NginxPath == "" { + opts.NginxPath = filepath.Join(dir, config.NginxFileName) + } + if opts.SnippetPath == "" { + opts.SnippetPath = filepath.Join(dir, "reverse-proxy.conf") + } + if opts.SecretDir == "" { + opts.SecretDir = dir + } + + res := &Result{ + Config: cfg, + EnvPath: opts.EnvPath, + ComposePath: opts.ComposePath, + NginxPath: opts.NginxPath, + SnippetPath: opts.SnippetPath, + StartTime: time.Now(), + } + + // Secrets: ensure they exist (or rotate if requested). + hulyPath := filepath.Join(opts.SecretDir, config.HulySecretFile) + crPath := filepath.Join(opts.SecretDir, config.CRSecretFile) + rpPath := filepath.Join(opts.SecretDir, config.RedpandaSecretFile) + hulySecret, err := secrets.Ensure(hulyPath, opts.RotateSecrets) + if err != nil { + return nil, fmt.Errorf("ensure huly secret: %w", err) + } + crSecret, err := secrets.Ensure(crPath, opts.RotateSecrets) + if err != nil { + return nil, fmt.Errorf("ensure cr secret: %w", err) + } + rpSecret, err := secrets.Ensure(rpPath, opts.RotateSecrets) + if err != nil { + return nil, fmt.Errorf("ensure rp secret: %w", err) + } + res.HulySecret = hulySecret + res.CRSecret = crSecret + res.RedpandaSecret = rpSecret + + // Render compose. + composeOut, err := compose.Render(cfg) + if err != nil { + return nil, fmt.Errorf("render compose: %w", err) + } + res.ComposeRendered = composeOut + + // Render env. + envOut, err := envconf.Render(cfg, hulySecret, crSecret, rpSecret) + if err != nil { + return nil, fmt.Errorf("render env: %w", err) + } + res.EnvRendered = envOut + + // Render nginx. + if cfg.Topology == config.TopologyBuiltin { + nginxOut, err := nginx.Render(cfg) + if err != nil { + return nil, fmt.Errorf("render nginx: %w", err) + } + res.NginxRendered = nginxOut + } + if cfg.Topology == config.TopologyReverse { + snippetOut, err := nginx.ReverseProxySnippet(cfg) + if err != nil { + return nil, fmt.Errorf("render snippet: %w", err) + } + res.SnippetRendered = snippetOut + } + + // Persist artifacts. + if !cfg.DryRun { + if err := writeFile(opts.EnvPath, envOut); err != nil { + return nil, err + } + if err := writeFile(opts.ComposePath, composeOut); err != nil { + return nil, err + } + // Maintain backwards-compat: write .env symlink if absent. + envLink := filepath.Join(dir, ".env") + if _, err := os.Lstat(envLink); err != nil { + _ = os.Symlink(filepath.Base(opts.EnvPath), envLink) + } + if res.NginxRendered != "" { + if err := writeFile(opts.NginxPath, res.NginxRendered); err != nil { + return nil, err + } + } + if res.SnippetRendered != "" { + if err := writeFile(opts.SnippetPath, res.SnippetRendered); err != nil { + return nil, err + } + } + } else { + fmt.Fprintln(out, "[dry-run] would write:") + for _, p := range []string{opts.EnvPath, opts.ComposePath} { + fmt.Fprintf(out, " - %s (%d bytes)\n", p, len(envOut)) + } + fmt.Fprintf(out, " (compose: %d bytes)\n", len(composeOut)) + if res.NginxRendered != "" { + fmt.Fprintf(out, " - %s (%d bytes)\n", opts.NginxPath, len(res.NginxRendered)) + } + if res.SnippetRendered != "" { + fmt.Fprintf(out, " - %s (%d bytes)\n", opts.SnippetPath, len(res.SnippetRendered)) + } + } + + if !opts.OnlyRender && !cfg.SkipPull && !cfg.DryRun { + if err := inv.Pull(ctx, dir); err != nil { + return nil, fmt.Errorf("docker compose pull: %w", err) + } + res.DockerActions = append(res.DockerActions, "pull") + } + if !opts.OnlyRender && !cfg.SkipUp && !cfg.DryRun { + if err := inv.Up(ctx, dir, true); err != nil { + return nil, fmt.Errorf("docker compose up: %w", err) + } + res.DockerActions = append(res.DockerActions, "up -d") + } else if cfg.DryRun && !opts.OnlyRender { + // Still let the invoker log the intent; useful for `--dry-run --only-render=false`. + _ = inv.Pull(ctx, dir) + _ = inv.Up(ctx, dir, true) + } + + res.Duration = time.Since(res.StartTime) + return res, nil +} + +func writeFile(path, body string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.WriteString(body); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// PrintSummary writes a human-readable summary to out. +func PrintSummary(res *Result, out io.Writer) { + if out == nil { + out = os.Stdout + } + w := func(format string, args ...any) { + fmt.Fprintf(out, format+"\n", args...) + } + w("") + w("════════════════════════════════════════════════════════════════") + w(" Huly setup complete in %s", res.Duration.Round(time.Millisecond)) + w("════════════════════════════════════════════════════════════════") + w(" Profile: %s", res.Config.Profile) + w(" Topology: %s", res.Config.Topology) + w(" Host: %s", res.Config.HostAddress) + w(" Port: %d (secure=%v)", res.Config.HTTPPort, res.Config.Secure) + w(" Version: %s", res.Config.HulyVersion) + w(" Generated files:") + w(" env -> %s", res.EnvPath) + w(" compose -> %s", res.ComposePath) + if res.NginxRendered != "" { + w(" nginx -> %s", res.NginxPath) + } + if res.SnippetRendered != "" { + w(" snippet -> %s", res.SnippetPath) + } + if len(res.DockerActions) > 0 { + w(" Docker actions: %s", strings.Join(res.DockerActions, ", ")) + } + w("") + if res.Config.Topology == config.TopologyBuiltin { + w(" Open: http%s://%s", secure(res.Config.Secure), urlHost(res.Config)) + } else { + w(" Configure your reverse proxy with %s and you're done.", res.SnippetPath) + } +} + +func secure(b bool) string { + if b { + return "s" + } + return "" +} + +func urlHost(c config.Config) string { + if c.IsLocal() { + if c.Secure { + return fmt.Sprintf("localhost:%d", c.HTTPPort) + } + if c.HTTPPort == 80 { + return "localhost" + } + return fmt.Sprintf("localhost:%d", c.HTTPPort) + } + return c.HostAddress +} + +// IsCancellable lets the TUI short-circuit when the user hits Ctrl-C during a +// long pull. +func IsCancellable(err error) bool { + return errors.Is(err, context.Canceled) +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 00000000..b849a850 --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,108 @@ +package runner + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/docker" +) + +type fakeInv struct { + upCalled bool + pullCalled bool +} + +func (f *fakeInv) Info(ctx context.Context) (string, error) { return "v0.0", nil } +func (f *fakeInv) Pull(ctx context.Context, dir string) error { f.pullCalled = true; return nil } +func (f *fakeInv) Up(ctx context.Context, dir string, detach bool) error { + f.upCalled = true + return nil +} +func (f *fakeInv) Down(ctx context.Context, dir string) error { return nil } +func (f *fakeInv) Ps(ctx context.Context, dir string) (string, error) { return "[]", nil } +func (f *fakeInv) Command(ctx context.Context, dir string, args ...string) error { return nil } +func (f *fakeInv) CommandOutput(ctx context.Context, dir string, args ...string) (string, error) { + return "", nil +} + +func TestRunDryRun(t *testing.T) { + dir := t.TempDir() + t.Setenv("HULY_SETUP_DIR", dir) + + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true, DryRun: true} + c.ApplyDefaults() + c.Profile = config.ProfileSingle + c.Topology = config.TopologyBuiltin + + inv := &docker.DryRunInvoker{Logger: func(string) {}} + res, err := Run(context.Background(), c, Options{}, inv, io.Discard) + if err != nil { + t.Fatal(err) + } + if res.ComposeRendered == "" || res.EnvRendered == "" { + t.Fatal("expected rendered compose/env") + } + if _, err := os.Stat(filepath.Join(dir, "huly_v7.conf")); err == nil { + t.Fatal("dry-run should NOT write files") + } + if !strings.Contains(res.ComposeRendered, "memory: 1792M") { + t.Fatalf("expected single-tenant compose memory budgets in output:\n%s", snippet(res.ComposeRendered, 400)) + } +} + +func TestRunWritesFilesAndCallsDocker(t *testing.T) { + dir := t.TempDir() + t.Setenv("HULY_SETUP_DIR", dir) + + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true} + c.ApplyDefaults() + + fake := &fakeInv{} + res, err := Run(context.Background(), c, Options{}, fake, io.Discard) + if err != nil { + t.Fatal(err) + } + if !fake.pullCalled || !fake.upCalled { + t.Fatal("expected pull + up to be invoked") + } + if res.ComposePath == "" || res.EnvPath == "" { + t.Fatal("expected populated paths") + } + if _, err := os.Stat(res.ComposePath); err != nil { + t.Fatalf("compose.yml not written: %v", err) + } + if _, err := os.Stat(res.EnvPath); err != nil { + t.Fatalf("env file not written: %v", err) + } +} + +func TestRunReverseProxyOmitsNginx(t *testing.T) { + dir := t.TempDir() + t.Setenv("HULY_SETUP_DIR", dir) + + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true, Topology: config.TopologyReverse} + c.ApplyDefaults() + inv := &docker.DryRunInvoker{Logger: func(string) {}} + res, err := Run(context.Background(), c, Options{OnlyRender: true}, inv, io.Discard) + if err != nil { + t.Fatal(err) + } + if strings.Contains(res.ComposeRendered, "\n nginx:\n") { + t.Fatal("reverse-proxy compose should not include the nginx service") + } + if res.SnippetRendered == "" { + t.Fatal("expected reverse-proxy snippet") + } +} + +func snippet(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "...(truncated)" +} diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go new file mode 100644 index 00000000..01b84f9b --- /dev/null +++ b/internal/secrets/secrets.go @@ -0,0 +1,48 @@ +// Package secrets handles the three persistent secret files used by Huly: +// .huly.secret, .cr.secret, .rp.secret. Each file holds 32 bytes of hex. +package secrets + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" +) + +const hexBytes = 32 + +func Ensure(path string, force bool) (string, error) { + if !force { + if data, err := os.ReadFile(path); err == nil { + s := strip(string(data)) + if s != "" { + return s, nil + } + } + } + buf := make([]byte, hexBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random: %w", err) + } + secret := hex.EncodeToString(buf) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(path, []byte(secret+"\n"), 0o600); err != nil { + return "", err + } + return secret, nil +} + +func strip(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c == '\n' || c == '\r' || c == ' ' || c == '\t' { + continue + } + out = append(out, c) + } + return string(out) +} diff --git a/internal/secrets/secrets_test.go b/internal/secrets/secrets_test.go new file mode 100644 index 00000000..0a21c75d --- /dev/null +++ b/internal/secrets/secrets_test.go @@ -0,0 +1,49 @@ +package secrets + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnsureCreatesNew(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".huly.secret") + v, err := Ensure(path, false) + if err != nil { + t.Fatal(err) + } + if len(v) != 64 { + t.Fatalf("expected 64 hex chars, got %d", len(v)) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != v+"\n" { + t.Fatalf("file content mismatch: %q vs %q", data, v) + } +} + +func TestEnsureReuses(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".cr.secret") + v1, _ := Ensure(path, false) + v2, _ := Ensure(path, false) + if v1 != v2 { + t.Fatalf("expected reuse, got %q then %q", v1, v2) + } +} + +func TestEnsureForce(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".rp.secret") + v1, _ := Ensure(path, false) + v2, err := Ensure(path, true) + if err != nil { + t.Fatal(err) + } + if v1 == v2 { + t.Fatal("expected force rotation to produce a different secret") + } +} diff --git a/internal/tui/e2e_test.go b/internal/tui/e2e_test.go new file mode 100644 index 00000000..08601b55 --- /dev/null +++ b/internal/tui/e2e_test.go @@ -0,0 +1,180 @@ +package tui + +import ( + "bytes" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +// runWithKeys drives a full TUI session by feeding a sequence of key events. +// Returns the final model and rendered output. +func runWithKeys(t *testing.T, c config.Config, keys string) (Model, string) { + t.Helper() + in := strings.NewReader(keys) + var out bytes.Buffer + m := New(c) + p := tea.NewProgram(m, tea.WithInput(in), tea.WithOutput(&out)) + // Bubble Tea reads from input in a goroutine; give it a moment. + done := make(chan struct{}) + var final tea.Model + go func() { + f, err := p.Run() + if err != nil { + t.Errorf("tui.Run: %v", err) + } + final = f + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + p.Quit() + <-done + } + return final.(Model), out.String() +} + +func keys(ks ...tea.KeyType) string { + var b strings.Builder + for _, k := range ks { + switch k { + case tea.KeyEnter: + b.WriteByte('\r') + case tea.KeyEsc: + b.WriteString("\x1b") + case tea.KeyUp: + b.WriteString("\x1b[A") + case tea.KeyDown: + b.WriteString("\x1b[B") + case tea.KeyTab: + b.WriteByte('\t') + case tea.KeySpace: + b.WriteByte(' ') + default: + b.WriteString(string(rune(k))) + } + } + return b.String() +} + +func TestFullFlowSucceeds(t *testing.T) { + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true} + c.ApplyDefaults() + // Welcome → enter → Profile (default multi) → enter → Topology (builtin) + // → enter → Host (already set, just enter) → Port (already set, enter) + // → Secure (yes=0, enter) → Volumes (enter enter ... ) + // → Summary (enter) → Review (enter) → Confirm (y) + keys := keys(tea.KeyEnter) + // welcome + keys(tea.KeyEnter) + // profile + keys(tea.KeyEnter) + // topology (builtin default) + keys(tea.KeyEnter) + // host (already filled) + keys(tea.KeyEnter) + // port (already filled) + keys(tea.KeyEnter) + // secure (default yes = cursor 0) + keys(tea.KeyEnter) + // volumes (accept defaults) + keys(tea.KeyEnter) + // summary -> review + keys(tea.KeyEnter) + // review -> confirm + keys('y') // apply + + m, _ := runWithKeys(t, c, keys) + if m.aborted { + t.Fatalf("unexpected abort") + } + if m.step != stepDone { + t.Fatalf("expected stepDone, got %d", m.step) + } + // walk the model through the same flow by hand and verify the View at + // each step renders the right content. + steps := []step{ + stepWelcome, stepProfile, stepTopology, stepHost, stepPort, + stepSecure, stepVolumes, stepSummary, stepReview, stepConfirm, + } + wantSubstrings := []string{ + "Welcome!", + "Step 1/9", + "Step 2/9", + "Step 4/9", + "Step 5/9", + "Step 6/9", + "Step 7/9", + "Step 8/9", + "Step 9/9", + "Apply now?", + } + for i, s := range steps { + m2 := m + m2.step = s + v := m2.View() + if !strings.Contains(v, wantSubstrings[i]) { + t.Errorf("step %d view missing %q", s, wantSubstrings[i]) + } + } +} + +func TestCtrlCAborts(t *testing.T) { + c := config.Config{} + c.ApplyDefaults() + m, out := runWithKeys(t, c, keys(tea.KeyCtrlC)) + if !m.aborted { + t.Fatal("expected aborted flag") + } + if strings.Contains(out, "Welcome!") == false { + t.Fatal("expected welcome in output before abort") + } +} + +func TestBackNavigationFlow(t *testing.T) { + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true} + c.ApplyDefaults() + // Welcome -> enter -> Profile -> esc (back to welcome) + ks := keys(tea.KeyEnter) + keys(tea.KeyEsc) + m, _ := runWithKeys(t, c, ks) + if m.step != stepWelcome { + t.Fatalf("expected stepWelcome after back from profile, got %d", m.step) + } +} + +func TestReviewStepShowsFilesAndActions(t *testing.T) { + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true} + c.ApplyDefaults() + c.Profile = config.ProfileSingle + c.Topology = config.TopologyBuiltin + m := New(c) + m.step = stepReview + v := m.View() + for _, want := range []string{ + "Review changes", + "Files to write", + "Docker actions", + "Profile impact", + "single-tenant", + "Reachable at", + "huly_v7.conf", + "compose.yml", + } { + if !strings.Contains(v, want) { + t.Errorf("review missing %q", want) + } + } +} + +func TestReviewReverseProxyShowsSnippet(t *testing.T) { + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true} + c.ApplyDefaults() + c.Profile = config.ProfileSingle + c.Topology = config.TopologyReverse + c.ExposeMode = config.ExposeLocalhost + m := New(c) + m.step = stepReview + v := m.View() + if !strings.Contains(v, "reverse-proxy.conf") { + t.Error("review should mention reverse-proxy.conf for reverse-proxy topology") + } + if strings.Contains(v, ".huly.nginx") { + t.Error("review should not mention .huly.nginx for reverse-proxy topology") + } +} \ No newline at end of file diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 00000000..b6c15205 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,693 @@ +// Package tui implements the Bubble Tea interactive setup for huly-setup. +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/profile" +) + +type step int + +const ( + stepWelcome step = iota + stepProfile + stepTopology + stepExpose + stepHost + stepPort + stepSecure + stepVolumes + stepSummary + stepReview + stepConfirm + stepDone +) + +// back maps a step to the step that the "back" key should return to. +var back = map[step]step{ + stepProfile: stepWelcome, + stepTopology: stepProfile, + stepExpose: stepTopology, + stepHost: stepExpose, + stepPort: stepHost, + stepSecure: stepPort, + stepVolumes: stepSecure, + stepSummary: stepVolumes, + stepReview: stepSummary, + stepConfirm: stepReview, +} + +type Model struct { + cfg config.Config + step step + err error + + cursor int + hostInput string + portInput string + elasticPath string + filesPath string + crDataPath string + crCertsPath string + redpandaPath string + + width, height int + aborted bool +} + +func New(cfg config.Config) Model { + return Model{ + cfg: cfg, + step: stepWelcome, + hostInput: cfg.HostAddress, + portInput: fmt.Sprintf("%d", cfg.HTTPPort), + } +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + m.aborted = true + return m, tea.Quit + case "esc": + if prev, ok := back[m.step]; ok { + m.err = nil + m.cursor = 0 + m.step = prev + return m, nil + } + } + switch m.step { + case stepWelcome: + return m.updateWelcome(msg) + case stepProfile: + return m.updateProfile(msg) + case stepTopology: + return m.updateTopology(msg) + case stepExpose: + return m.updateExpose(msg) + case stepHost: + return m.updateHost(msg) + case stepPort: + return m.updatePort(msg) + case stepSecure: + return m.updateSecure(msg) + case stepVolumes: + return m.updateVolumes(msg) + case stepSummary: + return m.updateSummary(msg) + case stepReview: + return m.updateReview(msg) + case stepConfirm: + return m.updateConfirm(msg) + } + return m, nil +} + +func (m Model) updateWelcome(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter", " ": + m.step = stepProfile + } + return m, nil +} + +func (m Model) updateProfile(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < 1 { + m.cursor++ + } + case "enter", " ": + m.cfg.Profile = config.ProfileMulti + if m.cursor == 1 { + m.cfg.Profile = config.ProfileSingle + } + m.step = stepTopology + m.cursor = 0 + } + return m, nil +} + +func (m Model) updateTopology(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < 1 { + m.cursor++ + } + case "enter", " ": + m.cfg.Topology = config.TopologyBuiltin + if m.cursor == 1 { + m.cfg.Topology = config.TopologyReverse + } + m.cursor = 0 + if m.cfg.Topology == config.TopologyReverse { + m.step = stepExpose + } else { + m.step = stepHost + } + } + return m, nil +} + +func (m Model) updateExpose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < 2 { + m.cursor++ + } + case "enter", " ": + modes := []config.ExposeMode{config.ExposeLocalhost, config.ExposeAll, config.ExposeNetwork} + m.cfg.ExposeMode = modes[m.cursor] + m.step = stepHost + m.cursor = 0 + } + return m, nil +} + +func (m Model) updateHost(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + host := strings.TrimSpace(m.hostInput) + if host == "" { + m.err = fmt.Errorf("host address cannot be empty") + return m, nil + } + m.err = nil + m.cfg.HostAddress = host + m.step = stepPort + case "backspace": + if len(m.hostInput) > 0 { + m.hostInput = m.hostInput[:len(m.hostInput)-1] + } + default: + if len(msg.String()) == 1 { + m.hostInput += msg.String() + } + } + return m, nil +} + +func (m Model) updatePort(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + var port int + _, err := fmt.Sscanf(m.portInput, "%d", &port) + if err != nil || port < 1 || port > 65535 { + m.err = fmt.Errorf("port must be a number 1-65535") + return m, nil + } + m.err = nil + m.cfg.HTTPPort = port + m.step = stepSecure + m.cursor = 0 + case "backspace": + if len(m.portInput) > 0 { + m.portInput = m.portInput[:len(m.portInput)-1] + } + default: + if len(msg.String()) == 1 && (msg.String()[0] >= '0' && msg.String()[0] <= '9') { + m.portInput += msg.String() + } + } + return m, nil +} + +func (m Model) updateSecure(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < 1 { + m.cursor++ + } + case "enter", " ": + m.cfg.Secure = m.cursor == 0 + m.step = stepVolumes + m.cursor = 0 + m.elasticPath = m.cfg.VolumeElasticPath + m.filesPath = m.cfg.VolumeFilesPath + m.crDataPath = m.cfg.VolumeCRDataPath + m.crCertsPath = m.cfg.VolumeCRCertsPath + m.redpandaPath = m.cfg.VolumeRedpanda + } + return m, nil +} + +func (m Model) updateVolumes(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + m.cfg.VolumeElasticPath = strings.TrimSpace(m.elasticPath) + m.cfg.VolumeFilesPath = strings.TrimSpace(m.filesPath) + m.cfg.VolumeCRDataPath = strings.TrimSpace(m.crDataPath) + m.cfg.VolumeCRCertsPath = strings.TrimSpace(m.crCertsPath) + m.cfg.VolumeRedpanda = strings.TrimSpace(m.redpandaPath) + m.step = stepSummary + case "tab": + m.cursor = (m.cursor + 1) % 5 + case "backspace": + switch m.cursor { + case 0: + if len(m.elasticPath) > 0 { + m.elasticPath = m.elasticPath[:len(m.elasticPath)-1] + } + case 1: + if len(m.filesPath) > 0 { + m.filesPath = m.filesPath[:len(m.filesPath)-1] + } + case 2: + if len(m.crDataPath) > 0 { + m.crDataPath = m.crDataPath[:len(m.crDataPath)-1] + } + case 3: + if len(m.crCertsPath) > 0 { + m.crCertsPath = m.crCertsPath[:len(m.crCertsPath)-1] + } + case 4: + if len(m.redpandaPath) > 0 { + m.redpandaPath = m.redpandaPath[:len(m.redpandaPath)-1] + } + } + default: + if len(msg.String()) == 1 { + switch m.cursor { + case 0: + m.elasticPath += msg.String() + case 1: + m.filesPath += msg.String() + case 2: + m.crDataPath += msg.String() + case 3: + m.crCertsPath += msg.String() + case 4: + m.redpandaPath += msg.String() + } + } + } + return m, nil +} + +func (m Model) updateSummary(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + if err := m.cfg.Validate(); err != nil { + m.err = err + return m, nil + } + m.err = nil + m.step = stepReview + } + return m, nil +} + +func (m Model) updateReview(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + m.step = stepConfirm + case "b": + m.step = stepSummary + } + return m, nil +} + +func (m Model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y": + m.step = stepDone + return m, tea.Quit + case "n", "N": + m.step = stepReview + } + return m, nil +} + +func (m Model) View() string { + var b strings.Builder + b.WriteString(logo()) + b.WriteString("\n") + w := widthFor(m.width) + switch m.step { + case stepWelcome: + b.WriteString(m.viewWelcome(w)) + case stepProfile: + b.WriteString(m.viewProfile(w)) + case stepTopology: + b.WriteString(m.viewTopology(w)) + case stepExpose: + b.WriteString(m.viewExpose(w)) + case stepHost: + b.WriteString(m.viewHost(w)) + case stepPort: + b.WriteString(m.viewPort(w)) + case stepSecure: + b.WriteString(m.viewSecure(w)) + case stepVolumes: + b.WriteString(m.viewVolumes(w)) + case stepSummary: + b.WriteString(m.viewSummary(w)) + case stepReview: + b.WriteString(m.viewReview(w)) + case stepConfirm: + b.WriteString(m.viewConfirm(w)) + case stepDone: + b.WriteString(m.viewDone(w)) + } + return b.String() +} + + +func boolStr(b bool) string { + if b { + return ok.Render("yes") + } + return dim.Render("no") +} + +func orDefault(s string) string { + if s == "" { + return dim.Render("(named volume)") + } + return s +} + +func (m Model) Config() config.Config { return m.cfg } + +// Stub used by older code paths; intentionally returns the rendered profile +// description so callers can show context if they want. +var _ = profile.Description +func (m Model) viewWelcome(w int) string { + return box(w, + accent.Render("Welcome!")+"\n\n"+ + "This tool will generate the Huly self-host configuration. "+ + "It can run in two modes:\n\n"+ + " • "+ok.Render("Quick")+" — use defaults, skip prompts, start immediately\n"+ + " • "+accent.Render("Interactive")+" — step through the choices below\n\n"+ + keyStyle.Render(" enter ")+" "+helpStyle.Render("begin • ctrl+c abort"), + ) +} + +func (m Model) viewProfile(w int) string { + var b strings.Builder + b.WriteString(sectionTitle.Render(" Step 1/9 Deployment profile ")) + b.WriteString("\n\n") + b.WriteString(dim.Render("Huly is normally tuned for many concurrent users. For a self-hosted instance with a single user, we ship a memory-optimized variant. Which do you want?")) + b.WriteString("\n\n") + options := []string{ + profile.Short(config.ProfileMulti), + profile.Short(config.ProfileSingle), + } + for i, opt := range options { + if i == m.cursor { + b.WriteString(selected.Render("▶ ") + selected.Render(opt) + "\n") + } else { + b.WriteString(unselected.Render(" ") + opt + "\n") + } + } + b.WriteString("\n") + b.WriteString(keyStyle.Render(" ↑/↓ ") + helpStyle.Render("move ") + + keyStyle.Render(" enter ") + helpStyle.Render("confirm • ctrl+c abort")) + return box(w, b.String()) +} + +func (m Model) viewTopology(w int) string { + var b strings.Builder + b.WriteString(sectionTitle.Render(" Step 2/9 Network topology ")) + b.WriteString("\n\n") + b.WriteString(dim.Render("How is the stack exposed to browsers?")) + b.WriteString("\n\n") + options := [][]string{ + {"Built-in nginx container", "Ship an nginx service in the compose stack; it binds to the host on the port you choose. Simplest option. (Your external reverse proxy can still forward to it.)"}, + {"Behind a reverse proxy", "Skip the nginx container. Generate a paste-ready snippet for your existing nginx / caddy / traefik."}, + } + for i, opt := range options { + mark := " " + if i == m.cursor { + mark = selected.Render("▶ ") + } + b.WriteString(mark + opt[0] + "\n") + b.WriteString(" " + dim.Render(opt[1]) + "\n") + } + b.WriteString("\n") + b.WriteString(keyStyle.Render(" ↑/↓ ") + helpStyle.Render("move ") + + keyStyle.Render(" enter ") + helpStyle.Render("confirm • esc back • ctrl+c abort")) + return box(w, b.String()) +} + +func (m Model) viewExpose(w int) string { + var b strings.Builder + b.WriteString(sectionTitle.Render(" Step 3/9 How does your proxy reach the services? ")) + b.WriteString("\n\n") + b.WriteString(dim.Render("The in-stack nginx is gone. Each service (front, account, transactor...) needs a way to be reached. Pick how the proxy connects:")) + b.WriteString("\n\n") + options := [][]string{ + {"127.0.0.1 — bind to localhost", "RECOMMENDED when your proxy runs on this host (system nginx, caddy, traefik). Services are bound to 127.0.0.1: — not reachable from the network."}, + {"0.0.0.0 — bind to all interfaces", "Same as above, but services are reachable from any host interface. Useful when the proxy runs on a different host."}, + {"Docker network only", "Don't publish any host ports. Your proxy container must join the huly_net network and reach services by hostname (e.g. http://front:8080)."}, + } + for i, opt := range options { + mark := " " + if i == m.cursor { + mark = selected.Render("▶ ") + } + b.WriteString(mark + opt[0] + "\n") + b.WriteString(" " + dim.Render(opt[1]) + "\n\n") + } + b.WriteString(keyStyle.Render(" ↑/↓ ") + helpStyle.Render("move ") + + keyStyle.Render(" enter ") + helpStyle.Render("confirm • esc back • ctrl+c abort")) + return box(w, b.String()) +} + +func (m Model) viewHost(w int) string { + var b strings.Builder + b.WriteString(sectionTitle.Render(" Step 4/9 Host address ")) + b.WriteString("\n\n") + b.WriteString(dim.Render("Domain name or IP that browsers will use to reach Huly. Press Enter for default (localhost).")) + b.WriteString("\n\n") + b.WriteString(accent.Render("> ") + m.hostInput + "█\n") + if m.err != nil { + b.WriteString("\n" + errStyle.Render(m.err.Error()) + "\n") + } + b.WriteString("\n") + b.WriteString(keyStyle.Render(" enter ") + helpStyle.Render("continue • esc back • ctrl+c abort")) + return box(w, b.String()) +} + +func (m Model) viewPort(w int) string { + var b strings.Builder + b.WriteString(sectionTitle.Render(" Step 5/9 HTTP port ")) + b.WriteString("\n\n") + b.WriteString(dim.Render("Host port the nginx container (or your reverse proxy) will bind to. Use 80 for plaintext, 443 for TLS-terminated-by-proxy.")) + b.WriteString("\n\n") + b.WriteString(accent.Render("> ") + m.portInput + "█\n") + if m.err != nil { + b.WriteString("\n" + errStyle.Render(m.err.Error()) + "\n") + } + b.WriteString("\n") + b.WriteString(keyStyle.Render(" enter ") + helpStyle.Render("continue • esc back • ctrl+c abort")) + return box(w, b.String()) +} + +func (m Model) viewSecure(w int) string { + var b strings.Builder + b.WriteString(sectionTitle.Render(" Step 6/9 TLS ")) + b.WriteString("\n\n") + b.WriteString(dim.Render("Is Huly served over HTTPS? (If using a reverse proxy, choose yes and terminate TLS upstream.)")) + b.WriteString("\n\n") + options := []string{"Yes — generate URLs with https://", "No — use plain http://"} + for i, opt := range options { + mark := " " + if i == m.cursor { + mark = selected.Render("▶ ") + } + b.WriteString(mark + opt + "\n") + } + b.WriteString("\n") + b.WriteString(keyStyle.Render(" ↑/↓ ") + helpStyle.Render("move ") + + keyStyle.Render(" enter ") + helpStyle.Render("continue • esc back • ctrl+c abort")) + return box(w, b.String()) +} + +func (m Model) viewVolumes(w int) string { + var b strings.Builder + b.WriteString(sectionTitle.Render(" Step 7/9 Persistent storage ")) + b.WriteString("\n\n") + b.WriteString(dim.Render("Leave blank to use Docker named volumes, or enter a host path. Press Tab to switch fields. Press Enter when done.")) + b.WriteString("\n\n") + fields := []struct { + label string + val *string + }{ + {"Elasticsearch", &m.elasticPath}, + {"Files (MinIO)", &m.filesPath}, + {"CockroachDB data", &m.crDataPath}, + {"CockroachDB certs", &m.crCertsPath}, + {"Redpanda", &m.redpandaPath}, + } + for i, f := range fields { + marker := " " + if i == m.cursor { + marker = selected.Render("▶ ") + } + val := *f.val + cursor := "" + if i == m.cursor { + cursor = "█" + } + display := val + if display == "" { + display = dim.Render("(named volume)") + } + b.WriteString(fmt.Sprintf("%s%-18s %s%s\n", marker, f.label+":", display, cursor)) + } + b.WriteString("\n") + b.WriteString(keyStyle.Render(" tab ") + helpStyle.Render("next field ") + + keyStyle.Render(" enter ") + helpStyle.Render("continue • esc back • ctrl+c abort")) + return box(w, b.String()) +} + +func (m Model) viewSummary(w int) string { + var b strings.Builder + b.WriteString(sectionTitle.Render(" Step 8/9 Configuration summary ")) + b.WriteString("\n\n") + rows := []struct{ k, v string }{ + {"Profile", string(m.cfg.Profile)}, + {"Topology", string(m.cfg.Topology)}, + {"Host", m.cfg.HostAddress}, + {"Port", fmt.Sprintf("%d", m.cfg.HTTPPort)}, + {"TLS", boolStr(m.cfg.Secure)}, + {"Elastic volume", orDefault(m.cfg.VolumeElasticPath)}, + {"Files volume", orDefault(m.cfg.VolumeFilesPath)}, + {"CR data volume", orDefault(m.cfg.VolumeCRDataPath)}, + {"CR certs volume", orDefault(m.cfg.VolumeCRCertsPath)}, + {"Redpanda volume", orDefault(m.cfg.VolumeRedpanda)}, + {"Huly version", m.cfg.HulyVersion}, + } + if m.cfg.Topology == config.TopologyReverse { + rows = append(rows, struct{ k, v string }{k: "Expose", v: string(m.cfg.ExposeMode)}) + } + for _, r := range rows { + b.WriteString(fmt.Sprintf(" %-18s %s\n", dim.Render(r.k+":"), r.v)) + } + b.WriteString("\n") + b.WriteString(keyStyle.Render(" enter ") + helpStyle.Render("review changes • esc back • ctrl+c abort")) + return box(w, b.String()) +} + +// viewReview shows exactly what the runner will write and run. This is the +// last gate before execution. +func (m Model) viewReview(w int) string { + var b strings.Builder + b.WriteString(sectionTitle.Render(" Step 9/9 Review changes ")) + b.WriteString("\n\n") + b.WriteString(dim.Render("The following will happen if you confirm:")) + b.WriteString("\n\n") + + b.WriteString(accent.Render("Files to write") + "\n") + b.WriteString(" " + dim.Render("• huly_v7.conf ") + "env, secrets, host\n") + b.WriteString(" " + dim.Render("• compose.yml ") + "services, ports, profiles\n") + if m.cfg.Topology == config.TopologyBuiltin { + b.WriteString(" " + dim.Render("• .huly.nginx ") + "in-container nginx config\n") + } else { + b.WriteString(" " + dim.Render("• reverse-proxy.conf") + " snippet for your proxy\n") + } + b.WriteString(" " + dim.Render("• .huly.secret, .cr.secret, .rp.secret") + " 32-byte hex\n") + + b.WriteString("\n") + b.WriteString(accent.Render("Docker actions") + "\n") + b.WriteString(" " + dim.Render("• docker compose pull ") + "(unless --skip-pull)\n") + b.WriteString(" " + dim.Render("• docker compose up -d ") + "(unless --skip-up)\n") + + b.WriteString("\n") + b.WriteString(accent.Render("Profile impact") + "\n") + if m.cfg.Profile == config.ProfileSingle { + b.WriteString(" " + ok.Render("single-tenant") + " ~4 GB RAM at idle, ~6 GB peak.\n") + b.WriteString(" Logging rotated per-service.\n") + b.WriteString(" Disabled: signup, passwords, recover,\n") + b.WriteString(" auto-translate, mailboxes.\n") + } else { + b.WriteString(" " + dim.Render("multi-tenant") + " unbounded resources,\n upstream-equivalent.\n") + } + + if m.cfg.Topology == config.TopologyBuiltin { + b.WriteString("\n") + b.WriteString(accent.Render("Reachable at") + "\n") + scheme := "http" + if m.cfg.Secure { + scheme = "https" + } + url := fmt.Sprintf("%s://%s", scheme, m.cfg.HostAddress) + if !m.cfg.IsLocal() && m.cfg.HTTPPort != 80 && m.cfg.HTTPPort != 443 { + url = fmt.Sprintf("%s://%s:%d", scheme, m.cfg.HostAddress, m.cfg.HTTPPort) + } + b.WriteString(" " + ok.Render(url) + "\n") + } else { + b.WriteString("\n") + b.WriteString(accent.Render("After apply") + "\n") + b.WriteString(" Configure your proxy with " + ok.Render("reverse-proxy.conf") + "\n") + } + + if m.cfg.DryRun { + b.WriteString("\n") + b.WriteString(warn.Render("DRY RUN") + " no files written, no containers started\n") + } + + b.WriteString("\n") + b.WriteString(keyStyle.Render(" enter ") + helpStyle.Render("confirm & apply • ") + + keyStyle.Render(" b ") + helpStyle.Render("back to summary • ctrl+c abort")) + return box(w, b.String()) +} + +func (m Model) viewConfirm(w int) string { + var b strings.Builder + b.WriteString(sectionTitle.Render(" Apply now? ")) + b.WriteString("\n\n") + b.WriteString(accent.Render("Apply changes and start the Huly stack?")) + b.WriteString("\n\n") + if m.cfg.DryRun { + b.WriteString(warn.Render("DRY RUN") + dim.Render(" — nothing will be written or started.") + "\n\n") + } + b.WriteString(dim.Render("Answer ")) + b.WriteString(ok.Render("Y") + dim.Render(" to apply, ")) + b.WriteString(errStyle.Render("N") + dim.Render(" to go back, or ")) + b.WriteString(dim.Render("press ctrl+c to abort.")) + return box(w, b.String()) +} + +func (m Model) viewDone(w int) string { + return box(w, + ok.Render("✓ Configuration applied.")+"\n\n"+ + dim.Render("The runner is now executing docker compose pull + up. "+ + "Watch for the summary that follows the program exit.")) +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 00000000..8545b4b4 --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,153 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +func TestNewModelDefaults(t *testing.T) { + c := config.Config{} + c.ApplyDefaults() + m := New(c) + if m.step != stepWelcome { + t.Fatalf("expected initial step Welcome, got %d", m.step) + } + if m.cfg.Profile != config.ProfileMulti { + t.Fatalf("expected default profile multi, got %q", m.cfg.Profile) + } +} + +func press(m Model, k tea.KeyType) Model { + updated, _ := m.Update(tea.KeyMsg{Type: k}) + return updated.(Model) +} + +func TestWelcomeAdvancesOnEnter(t *testing.T) { + c := config.Config{} + c.ApplyDefaults() + mm := press(New(c), tea.KeyEnter) + if mm.step != stepProfile { + t.Fatalf("expected profile step, got %d", mm.step) + } +} + +func TestProfileSelection(t *testing.T) { + c := config.Config{} + c.ApplyDefaults() + m := New(c) + m.step = stepProfile + m.cursor = 1 + mm := press(m, tea.KeyEnter) + if mm.cfg.Profile != config.ProfileSingle { + t.Fatalf("expected single profile, got %q", mm.cfg.Profile) + } + if mm.step != stepTopology { + t.Fatalf("expected topology step, got %d", mm.step) + } +} + +func TestTopologyReverse(t *testing.T) { + c := config.Config{} + c.ApplyDefaults() + m := New(c) + m.step = stepTopology + m.cursor = 1 + mm := press(m, tea.KeyEnter) + if mm.cfg.Topology != config.TopologyReverse { + t.Fatalf("expected reverse topology, got %q", mm.cfg.Topology) + } +} + +func TestHostValidation(t *testing.T) { + c := config.Config{} + c.ApplyDefaults() + m := New(c) + m.step = stepHost + m.hostInput = "" + mm := press(m, tea.KeyEnter) + if mm.err == nil { + t.Fatal("expected validation error for empty host") + } + m2 := New(c) + m2.step = stepHost + m2.hostInput = "huly.example.com" + mm2 := press(m2, tea.KeyEnter) + if mm2.step != stepPort { + t.Fatalf("expected port step, got %d", mm2.step) + } + if mm2.cfg.HostAddress != "huly.example.com" { + t.Fatalf("expected host set, got %q", mm2.cfg.HostAddress) + } +} + +func TestPortValidation(t *testing.T) { + c := config.Config{} + c.ApplyDefaults() + m := New(c) + m.step = stepPort + m.portInput = "70000" + mm := press(m, tea.KeyEnter) + if mm.err == nil { + t.Fatal("expected validation error for bad port") + } + m2 := New(c) + m2.step = stepPort + m2.portInput = "443" + mm2 := press(m2, tea.KeyEnter) + if mm2.step != stepSecure { + t.Fatalf("expected secure step, got %d", mm2.step) + } +} + +func TestViewSummaryContainsKey(t *testing.T) { + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true, Profile: config.ProfileSingle, Topology: config.TopologyBuiltin} + c.ApplyDefaults() + m := New(c) + m.step = stepSummary + v := m.View() + for _, want := range []string{"summary", "Profile", "Host"} { + if !strings.Contains(v, want) { + t.Errorf("view missing %q", want) + } + } +} + +func TestReviewStepShowsChanges(t *testing.T) { + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true} + c.ApplyDefaults() + c.Profile = config.ProfileSingle + c.Topology = config.TopologyBuiltin + m := New(c) + m.step = stepReview + v := m.View() + for _, want := range []string{"Review", "Files to write", "Docker actions", "single-tenant", "Reachable at"} { + if !strings.Contains(v, want) { + t.Errorf("review view missing %q", want) + } + } +} + +func TestBackNavigation(t *testing.T) { + c := config.Config{HostAddress: "huly.example.com", HTTPPort: 443, Secure: true} + c.ApplyDefaults() + m := New(c) + m.step = stepExpose + mm := press(m, tea.KeyEsc) + if mm.step != stepTopology { + t.Fatalf("expected stepTopology, got %d", mm.step) + } +} + +func TestCtrlCMarksAborted(t *testing.T) { + c := config.Config{} + c.ApplyDefaults() + m := New(c) + mm := press(m, tea.KeyCtrlC) + if !mm.aborted { + t.Fatal("expected aborted flag set on ctrl+c") + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 00000000..47472c92 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,94 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/wrap" +) + +var ( + brand = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7c3aed")). + Bold(true) + + accent = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22d3ee")). + Bold(true) + + dim = lipgloss.NewStyle().Foreground(lipgloss.Color("#71717a")) + + ok = lipgloss.NewStyle().Foreground(lipgloss.Color("#10b981")).Bold(true) + + warn = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b")).Bold(true) + + errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")).Bold(true) + + keyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#0a0a0a")). + Background(lipgloss.Color("#7c3aed")). + Bold(true). + Padding(0, 1) + + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#a1a1aa")) + + sectionTitle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#fafafa")). + Background(lipgloss.Color("#3f3f46")). + Bold(true). + Padding(0, 1) + + selected = lipgloss.NewStyle().Foreground(lipgloss.Color("#22d3ee")).Bold(true) + unselected = lipgloss.NewStyle().Foreground(lipgloss.Color("#a1a1aa")) +) + +// box builds the standard bordered container. The body is wrapped to fit the +// requested outer width, with the actual content area being outer - 6 (1 border +// + 1 padding on each side, plus a small safety margin so word-wrap doesn't +// break at exactly the rightmost column). +func box(outerWidth int, s string) string { + if outerWidth < 24 { + outerWidth = 24 + } + contentWidth := outerWidth - 6 + if contentWidth < 14 { + contentWidth = 14 + } + body := wrap.String(s, contentWidth) + b := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#3f3f46")). + Padding(0, 1). + MarginTop(1). + MarginBottom(1). + Width(outerWidth) + return b.Render(body) +} + +// widthFor returns the available content width inside the box given the +// terminal width. Leaves 2 columns of breathing room on each side. +func widthFor(termW int) int { + if termW <= 0 { + return 76 + } + w := termW - 4 + if w < 20 { + w = 20 + } + if w > 100 { + w = 100 + } + return w +} + +// logo returns the banner. It's drawn at its natural width so it stays crisp. +func logo() string { + return brand.Render(strings.TrimRight(` + ██╗ ██╗██╗ ██╗██╗ ██╗ ██╗ + ██║ ██║██║ ██║██║ ╚██╗ ██╔╝ + ███████║██║ ██║██║ ╚████╔╝ + ██╔══██║██║ ██║██║ ╚██╔╝ + ██║ ██║╚██████╔╝███████╗██║ + ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ + self-host setup`, "\n")) +} \ No newline at end of file diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 00000000..f79fcd91 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,35 @@ +package tui + +import ( + "fmt" + "io" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/hcengineering/huly-selfhost/cmd/huly-setup/internal/config" +) + +func Run(initial config.Config, in io.Reader, out io.Writer) (config.Config, error) { + m := New(initial) + p := tea.NewProgram(m, tea.WithInput(in), tea.WithOutput(out)) + final, err := p.Run() + if err != nil { + return config.Config{}, fmt.Errorf("tui: %w", err) + } + fm, ok := final.(Model) + if !ok { + return config.Config{}, fmt.Errorf("tui: unexpected model type %T", final) + } + if fm.aborted { + return config.Config{}, ErrAborted + } + return fm.cfg, nil +} + +// ErrAborted is returned by Run when the user pressed ctrl+c. +var ErrAborted = fmt.Errorf("tui: aborted") + +// WasAborted reports whether the config returned by Run was produced by an +// aborted session. It's always false in practice because aborted runs return +// ErrAborted, but kept for symmetry / future use. +func WasAborted(_ config.Config) bool { return false } diff --git a/nginx.sh b/nginx.sh deleted file mode 100755 index 9dcec872..00000000 --- a/nginx.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash - -if [ -f ".env" ]; then - source ".env" -fi - -# Check for --recreate flag -RECREATE=false -if [ "$1" == "--recreate" ]; then - RECREATE=true -fi - -# Handle nginx.conf recreation or updating -if [ "$RECREATE" == true ]; then - cp .template.nginx.conf nginx.conf - echo "nginx.conf has been recreated from the template." -else - if [ ! -f "nginx.conf" ]; then - echo "nginx.conf not found, creating from template." - cp .template.nginx.conf nginx.conf - else - echo "nginx.conf already exists. Only updating server_name, listen, and proxy_pass." - echo "Run with --recreate to fully overwrite nginx.conf." - fi -fi - -# Update server_name and proxy_pass using sed -sed -i.bak "s|server_name .*;|server_name ${HOST_ADDRESS};|" ./nginx.conf -sed -i.bak "s|proxy_pass .*;|proxy_pass http://${HTTP_BIND:-127.0.0.1}:${HTTP_PORT};|" ./nginx.conf - -# Update listen directive to either port 80 or 443, while preserving IP address -if [[ -n "$SECURE" ]]; then - # Secure (use port 443 and add 'ssl') - sed -i.bak -E 's|(listen )(.*:)?([0-9]+)?;|\1443 ssl;|' ./nginx.conf - echo "Serving over SSL. Make sure to add your SSL certificates." -else - # Non-secure (use port 80 and remove 'ssl') - sed -i.bak -E "s|(listen )(.*:)?[0-9]+ ssl;|\1\280;|" ./nginx.conf - sed -i.bak -E "s|(listen )(.*:)?([0-9]+)?;|\1\280;|" ./nginx.conf -fi - -# Extract IP address for redirect configuration -IP_ADDRESS=$(grep -oE 'listen \K[^:]+(?=:[0-9]+ ssl;)' nginx.conf) - -# Remove HTTP to HTTPS redirect server block if SSL is enabled -if [[ -z "$SECURE" ]]; then - echo "Enabling SSL; removing HTTP to HTTPS redirect block..." - # Remove the entire server block for port 80 - if grep -q 'return 301 https://\$host\$request_uri;' nginx.conf; then - sed -i.bak '/# !/,/!/d' nginx.conf - fi -else - # Check if the HTTP to HTTPS redirect block already exists - if grep -q 'return 301 https://\$host\$request_uri;' nginx.conf; then - sed -i.bak '/# !/,/!/d' nginx.conf - fi - - echo "Creating HTTP to HTTPS redirect..." - echo -e "# ! DO NOT REMOVE COMMENT -# DO NOT MODIFY, CHANGES WILL BE OVERWRITTEN -server { - listen ${IP_ADDRESS:+${IP_ADDRESS}:}80; - server_name ${HOST_ADDRESS}; - return 301 https://\$host\$request_uri; -} -# DO NOT REMOVE COMMENT !" >> ./nginx.conf -fi - -read -p "Do you want to run 'nginx -s reload' now to load your updated Huly config? (Y/n): " RUN_NGINX -case "${RUN_NGINX:-Y}" in - [Yy]* ) - echo -e "\033[1;32mRunning 'nginx -s reload' now...\033[0m" - sudo nginx -s reload - ;; - [Nn]* ) - echo "You can run 'nginx -s reload' later to load your updated Huly config." - ;; -esac \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..fcd7f008 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Huly self-host one-line installer. +# +# Downloads the appropriate huly-setup binary for the host OS/arch into +# ~/.local/bin/huly-setup (or $INSTALL_DIR if set) and launches it. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/hcengineering/huly-selfhost/main/scripts/install.sh | bash +# curl -fsSL ... | bash -s -- --single-tenant --host=huly.example.com --port=443 --tls +# +# Environment overrides: +# HULY_SETUP_VERSION Tag/branch to install (default: latest GitHub release) +# HULY_SETUP_REPO GitHub repo (default: hcengineering/huly-selfhost) +# INSTALL_DIR Where to drop the binary (default: ~/.local/bin) + +set -euo pipefail + +REPO="${HULY_SETUP_REPO:-hcengineering/huly-selfhost}" +VERSION="${HULY_SETUP_VERSION:-}" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" +BIN_NAME="huly-setup" + +log() { printf '\033[1;36m[huly-setup]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[huly-setup]\033[0m %s\n' "$*" >&2; } +err() { printf '\033[1;31m[huly-setup]\033[0m %s\n' "$*" >&2; } + +detect_os_arch() { + local os arch + case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) err "Unsupported OS: $(uname -s)"; exit 1 ;; + esac + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) err "Unsupported arch: $(uname -m)"; exit 1 ;; + esac + echo "${os}_${arch}" +} + +resolve_version() { + if [ -n "$VERSION" ]; then + echo "$VERSION" + return + fi + log "Resolving latest release from GitHub..." + local url="https://api.github.com/repos/${REPO}/releases/latest" + if command -v curl >/dev/null 2>&1; then + VERSION="$(curl -fsSL "$url" | sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + elif command -v wget >/dev/null 2>&1; then + VERSION="$(wget -qO- "$url" | sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + else + err "Neither curl nor wget is available" + exit 1 + fi + if [ -z "$VERSION" ]; then + err "Could not determine latest version (try HULY_SETUP_VERSION=v0.7.426)" + exit 1 + fi + echo "$VERSION" +} + +download_binary() { + local target="$1" version="$2" url tmp + url="https://github.com/${REPO}/releases/download/${version}/huly-setup_${version}_${target}.tar.gz" + tmp="$(mktemp -d)" + log "Downloading $url" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$tmp/installer.tgz" + else + wget -q "$url" -O "$tmp/installer.tgz" + fi + tar -xzf "$tmp/installer.tgz" -C "$tmp" + install -d "$INSTALL_DIR" + install -m 0755 "$tmp/$BIN_NAME" "$INSTALL_DIR/$BIN_NAME" + rm -rf "$tmp" +} + +ensure_path() { + case ":$PATH:" in + *":$INSTALL_DIR:"*) return ;; + esac + warn "$INSTALL_DIR is not in PATH. Add it with:" + warn " export PATH=\"$INSTALL_DIR:\$PATH\"" +} + +main() { + local target version + target="$(detect_os_arch)" + version="$(resolve_version)" + mkdir -p "$INSTALL_DIR" + download_binary "$target" "$version" + log "Installed $BIN_NAME $version to $INSTALL_DIR/$BIN_NAME" + ensure_path + log "Launching interactive setup..." + exec "$INSTALL_DIR/$BIN_NAME" "$@" +} + +main "$@" diff --git a/setup.sh b/setup.sh index 86506162..fc0a6c5f 100755 --- a/setup.sh +++ b/setup.sh @@ -1,284 +1,31 @@ #!/usr/bin/env bash -CONFIG_FILE="huly_v7.conf" +# Thin shim around the Go `huly-setup` binary. The real implementation lives +# in cmd/huly-setup; this file exists so existing workflows that invoke +# `./setup.sh` keep working. +# +# Resolution order: +# 1. ./huly-setup on PATH or in $PWD +# 2. `go run ./cmd/huly-setup` (dev / no-binary users) +# +# All CLI args are forwarded as-is. -# Parse command line arguments -RESET_VOLUMES=false -SECRET=false -QUICK=false +set -euo pipefail -for arg in "$@"; do - case $arg in - --secret) - SECRET=true - ;; - --reset-volumes) - RESET_VOLUMES=true - ;; - --quick) - QUICK=true - ;; - --help) - echo "Usage: $0 [OPTIONS]" - echo "Options:" - echo " --secret Generate a new secret key" - echo " --reset-volumes Reset all volume paths to default Docker named volumes" - echo " --quick Quick setup with defaults (localhost:8087, no SSL, auto-start)" - echo " --help Show this help message" - exit 0 - ;; - *) - echo "Unknown option: $arg" - echo "Use --help for usage information" - exit 1 - ;; - esac -done - -if [ "$RESET_VOLUMES" == true ]; then - echo -e "\033[33m--reset-volumes flag detected: Resetting all volume paths to default Docker named volumes.\033[0m" - sed -i \ - -e '/^VOLUME_ELASTIC_PATH=/s|=.*|=|' \ - -e '/^VOLUME_FILES_PATH=/s|=.*|=|' \ - -e '/^VOLUME_CR_DATA_PATH=/s|=.*|=|' \ - -e '/^VOLUME_CR_CERTS_PATH=/s|=.*|=|' \ - -e '/^VOLUME_REDPANDA_PATH=/s|=.*|=|' \ - "$CONFIG_FILE" - exit 0 -fi - -# Quick mode: use all defaults, skip prompts -if [ "$QUICK" == true ]; then - echo -e "\033[1;34m🚀 Quick setup mode - using defaults for fast verification\033[0m" - _HOST_ADDRESS="localhost:8087" - _HTTP_PORT="8087" - _SECURE="" - _VOLUME_ELASTIC_PATH="" - _VOLUME_FILES_PATH="" - _VOLUME_CR_DATA_PATH="" - _VOLUME_CR_CERTS_PATH="" - _VOLUME_REDPANDA_PATH="" -else - -if [ -f "$CONFIG_FILE" ]; then - source "$CONFIG_FILE" -fi - -while true; do - if [[ -n "$HOST_ADDRESS" ]]; then - prompt_type="current" - prompt_value="${HOST_ADDRESS}" - else - prompt_type="default" - prompt_value="localhost" - fi - read -p "Enter the host address (domain name or IP) [${prompt_type}: ${prompt_value}]: " input - _HOST_ADDRESS="${input:-${HOST_ADDRESS:-localhost}}" - break -done - -while true; do - if [[ -n "$HTTP_PORT" ]]; then - prompt_type="current" - prompt_value="${HTTP_PORT}" - else - prompt_type="default" - prompt_value="80" - fi - read -p "Enter the port for HTTP [${prompt_type}: ${prompt_value}]: " input - _HTTP_PORT="${input:-${HTTP_PORT:-80}}" - if [[ "$_HTTP_PORT" =~ ^[0-9]+$ && "$_HTTP_PORT" -ge 1 && "$_HTTP_PORT" -le 65535 ]]; then - break - else - echo "Invalid port. Please enter a number between 1 and 65535." - fi -done - -echo "$_HOST_ADDRESS $HOST_ADDRESS $_HTTP_PORT $HTTP_PORT" - -if [[ "$_HOST_ADDRESS" == "localhost" || "$_HOST_ADDRESS" == "127.0.0.1" || "$_HOST_ADDRESS" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}:?$ ]]; then - _HOST_ADDRESS="${_HOST_ADDRESS%:}:${_HTTP_PORT}" - SECURE="" -else - while true; do - if [[ -n "$SECURE" ]]; then - prompt_type="current" - prompt_value="Yes" - else - prompt_type="default" - prompt_value="No" - fi - read -p "Will you serve Huly over SSL? (y/n) [${prompt_type}: ${prompt_value}]: " input - case "${input}" in - [Yy]* ) - _SECURE="true"; break;; - [Nn]* ) - _SECURE=""; break;; - "" ) - _SECURE="${SECURE:+true}"; break;; - * ) - echo "Invalid input. Please enter Y or N.";; - esac - done -fi - -# Volume path configuration -echo -e "\n\033[1;34mDocker Volume Configuration:\033[0m" - - echo "You can specify custom paths for persistent data storage, or leave empty to use default Docker named volumes." - echo -e "\033[33mTip: To revert from custom paths to default volumes, enter 'default' or just press Enter when prompted.\033[0m" - - # Elasticsearch volume configuration - if [[ -n "$VOLUME_ELASTIC_PATH" ]]; then - current_elastic="custom: $VOLUME_ELASTIC_PATH" - else - current_elastic="default Docker volume" - fi - read -p "Enter custom path for Elasticsearch volume [current: ${current_elastic}]: " input - if [[ "$input" == "default" ]]; then - _VOLUME_ELASTIC_PATH="" - else - _VOLUME_ELASTIC_PATH="${input:-${VOLUME_ELASTIC_PATH}}" - fi - - # Files volume configuration - if [[ -n "$VOLUME_FILES_PATH" ]]; then - current_files="custom: $VOLUME_FILES_PATH" - else - current_files="default Docker volume" - fi - read -p "Enter custom path for files volume [current: ${current_files}]: " input - if [[ "$input" == "default" ]]; then - _VOLUME_FILES_PATH="" - else - _VOLUME_FILES_PATH="${input:-${VOLUME_FILES_PATH}}" - fi - - # Cockroach data volume configuration - if [[ -n "$VOLUME_CR_DATA_PATH" ]]; then - current_cr_data="custom: $VOLUME_CR_DATA_PATH" - else - current_cr_data="default Docker volume" - fi - read -p "Enter custom path for CR data volume [current: ${current_cr_data}]: " input - if [[ "$input" == "default" ]]; then - _VOLUME_CR_DATA_PATH="" - else - _VOLUME_CR_DATA_PATH="${input:-${VOLUME_CR_DATA_PATH}}" - fi - - # Cockroach certs volume configuration - if [[ -n "$VOLUME_CR_CERTS_PATH" ]]; then - current_cr_certs="custom: $VOLUME_CR_CERTS_PATH" - else - current_cr_certs="default Docker volume" - fi - read -p "Enter custom path for CR certs volume [current: ${current_cr_certs}]: " input - if [[ "$input" == "default" ]]; then - _VOLUME_CR_CERTS_PATH="" - else - _VOLUME_CR_CERTS_PATH="${input:-${VOLUME_CR_CERTS_PATH}}" - fi - - # Redpanda volume configuration - if [[ -n "$VOLUME_REDPANDA_PATH" ]]; then - current_redpanda="custom: $VOLUME_REDPANDA_PATH" - else - current_redpanda="default Docker volume" - fi - read -p "Enter custom path for Redpanda volume [current: ${current_redpanda}]: " input - if [[ "$input" == "default" ]]; then - _VOLUME_REDPANDA_PATH="" - else - _VOLUME_REDPANDA_PATH="${input:-${VOLUME_REDPANDA_PATH}}" - fi - -fi # End of non-quick mode - -if [ ! -f .huly.secret ] || [ "$SECRET" == true ]; then - openssl rand -hex 32 > .huly.secret - echo "Secret generated and stored in .huly.secret" -else - echo -e "\033[33m.huly.secret already exists, not overwriting." - echo "Run this script with --secret to generate a new secret." -fi - -if [ ! -f .cr.secret ]; then - openssl rand -hex 32 > .cr.secret - echo "Secret generated and stored in .cr.secret" +if [ -x "./huly-setup" ]; then + exec ./huly-setup "$@" fi -if [ ! -f .rp.secret ]; then - openssl rand -hex 32 > .rp.secret - echo "Secret generated and stored in .rp.secret" +if command -v huly-setup >/dev/null 2>&1; then + exec huly-setup "$@" fi -export HOST_ADDRESS=$_HOST_ADDRESS -export SECURE=$_SECURE -export HTTP_PORT=$_HTTP_PORT -export HTTP_BIND=$HTTP_BIND -export TITLE=${TITLE:-Huly} -export DEFAULT_LANGUAGE=${DEFAULT_LANGUAGE:-en} -export LAST_NAME_FIRST=${LAST_NAME_FIRST:-true} -export CR_DATABASE=${CR_DATABASE:-defaultdb} -export CR_USERNAME=${CR_USERNAME:-selfhost} -export REDPANDA_ADMIN_USER=${REDPANDA_ADMIN_USER:-superadmin} -export VOLUME_ELASTIC_PATH=$_VOLUME_ELASTIC_PATH -export VOLUME_FILES_PATH=$_VOLUME_FILES_PATH -export VOLUME_CR_DATA_PATH=$_VOLUME_CR_DATA_PATH -export VOLUME_CR_CERTS_PATH=$_VOLUME_CR_CERTS_PATH -export VOLUME_REDPANDA_PATH=$_VOLUME_REDPANDA_PATH -export HULY_SECRET=$(cat .huly.secret) -export COCKROACH_SECRET=$(cat .cr.secret) -export REDPANDA_SECRET=$(cat .rp.secret) - -envsubst < .template.huly.conf > $CONFIG_FILE - -source "$CONFIG_FILE" -export CR_DB_URL=$CR_DB_URL - -echo -e "\n\033[1;34mConfiguration Summary:\033[0m" -echo -e "Host Address: \033[1;32m$_HOST_ADDRESS\033[0m" -echo -e "HTTP Port: \033[1;32m$_HTTP_PORT\033[0m" -if [[ -n "$SECURE" ]]; then - echo -e "SSL Enabled: \033[1;32mYes\033[0m" -else - echo -e "SSL Enabled: \033[1;31mNo\033[0m" +if command -v go >/dev/null 2>&1; then + if [ -d "cmd/huly-setup" ]; then + exec go run ./cmd/huly-setup "$@" + fi fi -echo -e "Elasticsearch Volume: \033[1;32m${_VOLUME_ELASTIC_PATH:-Docker named volume}\033[0m" -echo -e "Files Volume: \033[1;32m${_VOLUME_FILES_PATH:-Docker named volume}\033[0m" -echo -e "CockroachDB Volume: \033[1;32m${_VOLUME_CR_DATA_PATH:-Docker named volume}\033[0m" -echo -e "CockroachDB Certs Volume: \033[1;32m${_VOLUME_CR_CERTS_PATH:-Docker named volume}\033[0m" -echo -e "Redpanda Volume: \033[1;32m${_VOLUME_REDPANDA_PATH:-Docker named volume}\033[0m" -if [ "$QUICK" == true ]; then - echo -e "\033[1;32mRunning 'docker compose up -d' now...\033[0m" - docker compose up -d -else - read -p "Do you want to run 'docker compose up -d' now to start Huly? (Y/n): " RUN_DOCKER - case "${RUN_DOCKER:-Y}" in - [Yy]* ) - echo -e "\033[1;32mRunning 'docker compose up -d' now...\033[0m" - docker compose up -d - ;; - [Nn]* ) - echo "You can run 'docker compose up -d' later to start Huly." - ;; - esac -fi - -echo -e "\033[1;32mSetup is complete!\n Generating nginx.conf...\033[0m" -./nginx.sh - -if [ "$QUICK" == true ]; then - echo "" - echo -e "\033[1;34m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m" - echo -e "\033[1;32m✅ Quick setup complete!\033[0m" - echo -e "\033[1;34m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m" - echo "" - echo -e "🌐 Access Huly at: \033[1;36mhttp://localhost:8087\033[0m" - echo "" - echo -e "⏳ Wait ~60 seconds for all services to initialize..." - echo -e "📊 Check status with: \033[1;33mdocker compose ps\033[0m" - echo -e "📋 View logs with: \033[1;33mdocker compose logs -f\033[0m" - echo "" -fi +echo "setup.sh: huly-setup binary not found and 'go' is not on PATH." >&2 +echo "Install Go (https://go.dev/dl/) or run:" >&2 +echo " go build -o huly-setup ./cmd/huly-setup" >&2 +exit 1 \ No newline at end of file