diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..cd5ee94 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,100 @@ +name: Build + +on: + push: + branches: [main] + paths: + - "**/*.go" + - "**/Dockerfile" + - "cmd/**" + - "internal/**" + - "ui/**" + - "go.mod" + - "go.sum" + workflow_dispatch: + +env: + REGISTRY: ghcr.io + +jobs: + prepare: + runs-on: ubuntu-24.04 + outputs: + repo_owner: ${{ steps.meta.outputs.repo_owner }} + short_sha: ${{ steps.meta.outputs.short_sha }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Extract metadata + id: meta + run: | + echo "repo_owner=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + build-api: + runs-on: ubuntu-24.04 + needs: prepare + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + + - uses: docker/setup-buildx-action@v4 + with: + buildkitd-config-inline: | + [worker.oci] + compression-type = "zstd" + compression-level = 3 + + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Build and push API image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/${{ needs.prepare.outputs.repo_owner }}/appa-api:${{ needs.prepare.outputs.short_sha }} + ghcr.io/${{ needs.prepare.outputs.repo_owner }}/appa-api:main + cache-from: type=gha + cache-to: type=gha,mode=max + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + + build-ui: + runs-on: ubuntu-24.04 + needs: prepare + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + + - uses: docker/setup-buildx-action@v4 + with: + buildkitd-config-inline: | + [worker.oci] + compression-type = "zstd" + compression-level = 3 + + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Build and push UI image + uses: docker/build-push-action@v6 + with: + context: ./ui + push: true + tags: | + ghcr.io/${{ needs.prepare.outputs.repo_owner }}/appa-ui:${{ needs.prepare.outputs.short_sha }} + ghcr.io/${{ needs.prepare.outputs.repo_owner }}/appa-ui:main + cache-from: type=gha + cache-to: type=gha,mode=max + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72a7ec1..a4bb6e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,13 +7,18 @@ on: jobs: build-api: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read packages: write steps: - uses: actions/checkout@v6 - uses: docker/setup-buildx-action@v4 + with: + buildkitd-config-inline: | + [worker.oci] + compression-type = "zstd" + compression-level = 3 - name: Log in to GHCR run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin @@ -38,13 +43,18 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} build-ui: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read packages: write steps: - uses: actions/checkout@v6 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 + with: + buildkitd-config-inline: | + [worker.oci] + compression-type = "zstd" + compression-level = 3 - name: Log in to GHCR run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin @@ -69,11 +79,11 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} release: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d439e7..ab275d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ Open [http://localhost](http://localhost) in your browser. | `ui` | Vite dev server | This Compose flow is the local development path. Production operations use the -Appa CLI: the operator installs the CLI locally, creates an instance profile, +Appa CLI: the operator installs the CLI locally, creates a server profile, and uses Ansible-backed commands to provision Appa Server on a remote VPS. ## Product Surfaces @@ -35,9 +35,9 @@ Use these names consistently when discussing or documenting Appa: |---|---| | Appa | The whole product. | | Appa CLI | Local operator/developer command-line tool, binary `appa`. | -| Appa Server | Remote API, dashboard, and deployment runtime. | -| Appa Instance | One remote Appa Server installation managed by the CLI. | +| Appa Server | Remote API, dashboard, and deployment runtime on a VPS. | | Appa Stack | Server-side services: API, UI, PostgreSQL, BuildKit, Caddy. | +| Server config | Local CLI configuration for one server (`~/.appa/servers//config.toml`). | The install script (`scripts/install.sh`) installs the CLI binary. It should not be used as a script that is run directly on the VPS to install the server stack. @@ -46,8 +46,8 @@ The first-time production flow is: ```bash curl -fsSL https://raw.githubusercontent.com/theolujay/appa/main/scripts/install.sh | sh -appa instance init personal -appa instance set-host -i ~/.ssh/id_ed25519 personal root@203.0.113.10 +appa server init personal +appa server set-host -i ~/.ssh/id_ed25519 personal root@203.0.113.10 appa preflight personal appa setup personal ``` @@ -85,8 +85,8 @@ make build/cli # Build the Appa CLI binary ### Run CLI ```bash -make run/cli ARGS="instance init my-server" -make run/cli ARGS="instance list" +make run/cli ARGS="server init my-server" +make run/cli ARGS="server ls" ./bin/appa --help # Built binary go run ./cmd/cli --help # Direct without build ``` @@ -116,8 +116,8 @@ go run ./cmd/cli --help # Direct without build ├── internal/ │ ├── cli/ # CLI implementation │ │ ├── app.go # Root command -│ │ ├── commands/ # instance, preflight, setup, apply, status, logs, restart, upgrade -│ │ ├── config/ # TOML instance profiles +│ │ ├── commands/ # server, project, deploy, preflight, setup, apply, status, logs, restart, upgrade +│ │ ├── config/ # TOML server and project profiles │ │ ├── ansible/ # Inventory generation + ansible-playbook runner │ │ ├── ssh/ # SSH connectivity and command execution │ │ └── output/ # Tables, checkmarks, progress output @@ -159,11 +159,13 @@ cmd/ internal/cli/ ├── app.go # Root command construction ├── commands/ -│ ├── instance.go # appa instance init|set-host|list +│ ├── server.go # appa server init|edit|set-host|ls +│ ├── project.go # appa project init|edit|logs|stop|restart|env +│ ├── deploy.go # appa deploy │ ├── preflight.go # appa preflight │ ├── setup.go # appa setup and appa apply │ └── operations.go # appa status|logs|restart|upgrade -├── config/ # TOML instance profiles (~/.appa/instances//config.toml) +├── config/ # TOML server and project profiles (~/.appa/servers/, ~/.appa/projects/) ├── ansible/ # Inventory generation and ansible-playbook runner ├── ssh/ # SSH connectivity, command execution, identity file support └── output/ # Tables, checkmarks, section headers @@ -218,16 +220,18 @@ All routes are prefixed with `/v1/` and proxied through Caddy. ```bash appa --version # Show build version (git tag or "(devel)") -appa instance init # Create a local instance profile -appa instance edit # Open profile in $EDITOR, validates on save -appa instance set-host # Set SSH target, e.g. root@203.0.113.10 - -i, --identity-file # SSH private key for this instance -appa instance list # List known Appa instances +appa server init # Create a local server profile +appa server edit # Open profile in $EDITOR, validates on save +appa server set-host # Set SSH target, e.g. root@203.0.113.10 + -i, --identity-file # SSH private key for this server + --port # API port (e.g. 8080 for Vagrant forwarded port) +appa server ls # List known Appa servers appa preflight # Validate SSH, OS, ports, DNS, and inputs + --no-tty # Non-interactive mode (plain text output) appa setup # First-time remote Appa Server setup --force # Skip preflight checks --tags, --skip-tags # Pass Ansible tag filters -appa apply # Re-apply instance config idempotently +appa apply # Re-apply server config idempotently --tags, --skip-tags # Pass Ansible tag filters appa status # Show remote Appa Stack health appa logs # Tail Appa Stack logs @@ -237,9 +241,21 @@ appa restart # Restart the Appa Stack -s, --service # Restart only one service appa upgrade # Upgrade remote Appa Stack images --version # Pin to a specific version tag +appa project init # Create a project profile + -t, --target # Target server name + -n, --name # Project name (default: source dir name) +appa project edit # Open project config in $EDITOR +appa project logs # Stream deployment logs (WebSocket TUI) +appa project stop # Stop running or cancel pending deployment +appa project restart # Stop + trigger new deployment +appa project env set # Set a project environment variable +appa project env get # List project environment variables +appa project env unset # Remove a project environment variable +appa deploy # Deploy an initialized project + --quiet # Suppress rsync progress output ``` -**Profile config** (`~/.appa/instances//config.toml`, 0600 perms): +**Server config** (`~/.appa/servers//config.toml`, 0600 perms): ```toml name = "personal" ssh_host = "" @@ -251,12 +267,15 @@ smtp_host = "" smtp_port = 587 smtp_username = "" smtp_password = "" +api_base_url = "" +api_port = 0 ``` **Target format** for `set-host`: `user@host` or `user@host:port` (e.g. `root@203.0.113.10` or `root@203.0.113.10:2222`). -Longer term, project-level commands (`deploy`, `logs`, `env`, rollbacks) will -use the Appa Server API instead of SSH/Ansible. +Project-level commands (`deploy`, `logs`, `stop`, `restart`, `env`) use the +Appa Server API for all server-side operations. Only `server init`, `setup`, +and `apply` still use SSH and Ansible for host-level provisioning. ## Coding Conventions diff --git a/README.md b/README.md index c54fc8f..c489764 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,39 @@ ![GitHub License](https://img.shields.io/github/license/theolujay/appa) -Turn any VPS into your own zero-config deployment platform, effortlessly 🦬 +Turn any VPS into your own zero-config deployment platform, effortlessly! + +Just **connect** your domain and **push** your code. **Deploy** apps instantly without Dockerfiles or playing sysadmin on hard mode. Even better: **manage** your **entire fleet** of VPSs from **one terminal**, anywhere! + +A single person could wear both hats: operator + developer. -Just **connect** your domain and **push** your code. **Deploy** apps instantly without Dockerfiles or playing sysadmin on hard mode. Even better: **manage** your **entire fleet** of VPS instances from **one terminal**, anywhere! ``` -Operator Machine Remote Server (VPS) - └─ Appa CLI ├─ Caddy Gateway - │ ├─ Appa API + Dashboard - SSH + Ansible + rsync ├─ BuildKit + Railpack - └──────────────────────────┼─ PostgreSQL - └─ Your containers + ╭─────── Operator ──────╮ ╭────── Developer ──────╮ + │ │ │ │ + │ ┌──────────┐ │ │ · git push │ + │ │ Appa CLI │ │ |· appa deploy │ + │ └────┬─────┘ │ │ │ │ + │ │ │ │ │ │ + │ SSH · rsync │ │ API │ + ╰──────────│────────────╯ ╰───────────│───────────╯ + │ │ + manages deploys + ▼ ▼ +╭── Fleet of Servers (VPS) ──┬──────────────────────────────────╮ +│ │ │ +│ ┌─ nyc-prod ──────────┐ │ ┌─ lon-staging ─────────────┐ │ +│ │ Blog API · Admin UI│ │ │ Client Dashboard (stg) │ │ +│ └─────────────────────┘ │ └───────────────────────────┘ │ +│ ◄───────┼───────► │ +│ ┌─ fra-gateway ───────┐ │ ┌─ sfo-preview ─────────────┐ │ +│ │ Auth · Webhook │ │ │ PR previews · E2E tests │ │ +│ └─────────────────────┘ │ └───────────────────────────┘ │ +│ ◄───────┼───────► │ +│ ┌─ ams-worker ────────┐ │ ┌─ (more)...────────────────┐ │ +│ │ Queue · Cron jobs │ │ │ Any VPS, anywhere │ │ +│ └─────────────────────┘ │ └───────────────────────────┘ │ +╰────────────────────────────┴──────────────────────────────────╯ +Each server runs: Caddy · Appa API · BuildKit · PostgreSQL · Containers ``` ## Prerequisites @@ -26,23 +49,36 @@ Operator Machine Remote Server (VPS) ```bash curl -fsSL https://appa.theolujay.dev/install.sh | sh -appa instance init my-server -appa instance set-host my-server root@203.0.113.10 -i ~/.ssh/id_ed25519 +appa server init my-server +appa server set-host my-server root@203.0.113.10 -i ~/.ssh/id_ed25519 appa preflight my-server appa setup my-server ``` -Once setup completes, open the dashboard URL to register your admin account. + + +## Features + +| Area | Capabilities | +|---|---| +| **Server management** | Initialize, provision, configure, and monitor any number of VPS servers from one CLI. | +| **Auto-deploy** | One command ships source via rsync and triggers the build pipeline. Projects are auto-created on the server. | +| **Env vars** | Manage per-project environment variables through `appa project env set/get/unset`. | +| **Project lifecycle** | View deployment logs, stop running deployments, or restart with `appa project logs/stop/restart`. | +| **Zero-config builds** | Railpack auto-detects runtimes — no Dockerfiles needed. | +| **Web dashboard** | React UI for deployment history, project management, and monitoring. | ## User Guide -See [docs/user-guide.md](docs/user-guide.md) for the full walkthrough — installation, instance management, project deployment, and CLI reference. +See [docs/user-guide.md](docs/user-guide.md) for the full walkthrough — installation, server management, project deployment, environment variables, and CLI reference. ## Documentation | Doc | For | |---|---| -| [User Guide](docs/user-guide.md) | Setting up and using Appa day-to-day | -| [Architecture](docs/architecture.md) | Design decisions, invariants, and data paths | -| [Contributing](CONTRIBUTING.md) | Development setup, API routes, coding conventions | +| [User Guide](docs/user-guide.md) | Installing the CLI, provisioning servers, deploying projects, managing env vars | +| [Architecture](docs/architecture.md) | Design decisions, invariants, data paths, and glossary | +| [Contributing](CONTRIBUTING.md) | Development setup, API routes, project structure, coding conventions | | [Roadmap](docs/roadmap.md) | Completed milestones and planned features | diff --git a/cmd/api/deployments.go b/cmd/api/deployments.go index 0dda574..8f66261 100644 --- a/cmd/api/deployments.go +++ b/cmd/api/deployments.go @@ -17,8 +17,10 @@ func (app *application) createDeploymentHandler(w http.ResponseWriter, r *http.R user := app.contextGetUser(r) var p struct { - Source string `json:"source"` - EnvVars string `json:"env_vars"` + Source string `json:"source"` + EnvVars string `json:"env_vars"` + ProjectName string `json:"project_name"` + ProjectID *int64 `json:"project_id,omitempty"` } err := app.readJSON(w, r, &p) if err != nil { @@ -26,16 +28,51 @@ func (app *application) createDeploymentHandler(w http.ResponseWriter, r *http.R return } + var project *da.Project + + if p.ProjectName != "" { + project, err = app.models.Projects.GetByName(p.ProjectName) + if errors.Is(err, da.ErrRecordNotFound) { + project = &da.Project{ + Name: p.ProjectName, + UserID: &user.ID, + } + if user.IsAnonymous() { + project.UserID = nil + } + if err = app.models.Projects.Insert(project); err != nil { + app.serverErrorResponse(w, r, err) + return + } + } else if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } else if p.ProjectID != nil { + project, err = app.models.Projects.Get(*p.ProjectID) + if err != nil { + switch { + case errors.Is(err, da.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + } + d := da.Deployment{ Source: p.Source, EnvVars: &p.EnvVars, } + if project != nil { + d.ProjectID = &project.ID + } if !user.IsAnonymous() { d.UserID = &user.ID } - - if err = da.ValidateDeployment(&d); err != nil { + if err = da.ValidateDeployment(d); err != nil { app.failedValidationResponse(w, r, err) return } @@ -80,6 +117,7 @@ func (app *application) uploadProjectHandler(w http.ResponseWriter, r *http.Requ defer file.Close() envVars := r.FormValue("env_vars") + projectName := r.FormValue("project_name") dir := uuid.New().String() uploadDir := filepath.Join("/tmp", "appa-upload", dir) @@ -101,6 +139,27 @@ func (app *application) uploadProjectHandler(w http.ResponseWriter, r *http.Requ d.UserID = &user.ID } + if projectName != "" { + project, err := app.models.Projects.GetByName(projectName) + if errors.Is(err, da.ErrRecordNotFound) { + project = &da.Project{ + Name: projectName, + UserID: &user.ID, + } + if user.IsAnonymous() { + project.UserID = nil + } + if err = app.models.Projects.Insert(project); err != nil { + app.serverErrorResponse(w, r, err) + return + } + } else if err != nil { + app.serverErrorResponse(w, r, err) + return + } + d.ProjectID = &project.ID + } + if err = app.models.Deployments.Create(&d); err != nil { app.serverErrorResponse(w, r, err) return @@ -158,7 +217,7 @@ func unzip(r io.ReaderAt, size int64, dest string) error { return nil } -func (app *application) cancelDeploymentHandler(w http.ResponseWriter, r *http.Request) { +func (app *application) stopDeploymentHandler(w http.ResponseWriter, r *http.Request) { user := app.contextGetUser(r) id, err := app.readIDParam(r) @@ -190,24 +249,132 @@ func (app *application) cancelDeploymentHandler(w http.ResponseWriter, r *http.R return } - if err := app.pipeline.Cancel(d.ID); err != nil { + app.background(func() { + if err := app.pipeline.Stop(d.ID); err != nil { + app.logger.Error("stop deployment failed", "error", err) + } + }) + + err = app.writeJSON(w, http.StatusAccepted, envelope{"message": "stopping deployment"}, nil) + if err != nil { app.serverErrorResponse(w, r, err) return } +} + +func (app *application) restartDeploymentHandler(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + + id, err := app.readIDParam(r) + + if err != nil || id < 1 { + switch { + case errors.Is(err, ErrParamInvalid): + err = fmt.Errorf("%w: ID", err) + app.badRequestResponse(w, r, err) + default: + app.notFoundResponse(w, r) + } + return + } + + d, err := app.models.Deployments.Get(id) + if err != nil { + switch { + case errors.Is(err, da.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + if d.UserID != nil && *d.UserID != user.ID { + app.notPermittedResponse(w, r) + return + } + app.background(func() { + if err := app.pipeline.Restart(d.ID); err != nil { + app.logger.Error("restart deployment failed", "error", err) + } + }) + + err = app.writeJSON(w, http.StatusAccepted, envelope{"message": "restarting deployment"}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } +} + +func (app *application) getDeploymentHandler(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + + id, err := app.readIDParam(r) + if err != nil || id < 1 { + switch { + case errors.Is(err, ErrParamInvalid): + err = fmt.Errorf("%w: ID", err) + app.badRequestResponse(w, r, err) + default: + app.notFoundResponse(w, r) + } + return + } + + d, err := app.models.Deployments.Get(id) + if err != nil { + switch { + case errors.Is(err, da.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + if d.UserID != nil && *d.UserID != user.ID { + app.notPermittedResponse(w, r) + return + } + + err = app.writeJSON(w, http.StatusOK, envelope{"deployment": d}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } } func (app *application) listDeploymentsHandler(w http.ResponseWriter, r *http.Request) { user := app.contextGetUser(r) var q struct { - Status string + Status string + ProjectName string + ProjectID int64 da.Filters } qs := r.URL.Query() q.Status = app.readString(qs, "status", "") + q.ProjectName = app.readString(qs, "project_name", "") + pid, _ := app.readInt(qs, "project_id", 0) + q.ProjectID = int64(pid) + + if q.ProjectName != "" && q.ProjectID == 0 { + p, err := app.models.Projects.GetByName(q.ProjectName) + if err != nil { + switch { + case errors.Is(err, da.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + q.ProjectID = p.ID + } var errs []error page, err := app.readInt(qs, "page", 1) @@ -222,7 +389,7 @@ func (app *application) listDeploymentsHandler(w http.ResponseWriter, r *http.Re q.Filters.Page = page q.Filters.PageSize = pageSize q.Filters.Sort = app.readString(qs, "sort", "id") - q.Filters.SortSafelist = []string{"id", "status"} + q.Filters.SortSafelist = []string{"id", "status", "-id", "-status"} err = da.ValidateFilters(q.Filters) if err != nil { @@ -235,7 +402,7 @@ func (app *application) listDeploymentsHandler(w http.ResponseWriter, r *http.Re return } - d, m, err := app.models.Deployments.GetAllForUser(user.ID, q.Status, q.Filters) + d, m, err := app.models.Deployments.GetAllForUser(user.ID, q.Status, q.ProjectID, q.Filters) if err != nil { app.serverErrorResponse(w, r, err) return diff --git a/cmd/api/helpers.go b/cmd/api/helpers.go index f316031..3cb3c1f 100644 --- a/cmd/api/helpers.go +++ b/cmd/api/helpers.go @@ -223,8 +223,8 @@ func (app *application) readInt(qs url.Values, key string, defaultValue int) (in return i, nil } -// The background() helper accepts an arbitrary function as a parameter -// to run within a recover-able goroutine +// The background() helper accepts an arbitrary function +// as a parameter to run within a recover-able goroutine. func (app *application) background(fn func()) { app.wg.Go(func() { defer func() { diff --git a/cmd/api/projects.go b/cmd/api/projects.go new file mode 100644 index 0000000..bfabf0c --- /dev/null +++ b/cmd/api/projects.go @@ -0,0 +1,267 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + + da "github.com/theolujay/appa/internal/data" +) + +func (app *application) createProjectHandler(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + + var p struct { + Name string `json:"name"` + } + err := app.readJSON(w, r, &p) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + project := &da.Project{ + Name: p.Name, + UserID: &user.ID, + } + + if user.IsAnonymous() { + project.UserID = nil + } + + if err := app.models.Projects.Insert(project); err != nil { + switch { + case errors.Is(err, da.ErrDuplicateProject): + app.failedValidationResponse(w, r, fmt.Errorf("name: a project with this name already exists")) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/projects/%d", project.ID)) + + err = app.writeJSON(w, http.StatusCreated, envelope{"project": project}, headers) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listProjectsHandler(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + + qs := r.URL.Query() + name := app.readString(qs, "name", "") + + // If a name is provided, look up by name directly + if name != "" { + project, err := app.models.Projects.GetByName(name) + if err != nil { + switch { + case errors.Is(err, da.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + err = app.writeJSON(w, http.StatusOK, envelope{"projects": []da.Project{*project}}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } + return + } + + var q struct { + da.Filters + } + + var errs []error + page, err := app.readInt(qs, "page", 1) + if err != nil { + errs = append(errs, err) + } + pageSize, err := app.readInt(qs, "page_size", 20) + if err != nil { + errs = append(errs, err) + } + + q.Filters.Page = page + q.Filters.PageSize = pageSize + q.Filters.Sort = app.readString(qs, "sort", "id") + q.Filters.SortSafelist = []string{"id", "name", "created_at", "-id", "-name", "-created_at"} + + err = da.ValidateFilters(q.Filters) + if err != nil { + errs = append(errs, err) + } + + if len(errs) > 0 { + err = errors.Join(errs...) + app.failedValidationResponse(w, r, err) + return + } + + userID := int64(0) + if !user.IsAnonymous() { + userID = user.ID + } + + projects, metadata, err := app.models.Projects.GetAllForUser(userID, q.Filters) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + err = app.writeJSON(w, http.StatusOK, envelope{"projects": projects, "metadata": metadata}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) getProjectHandler(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + + id, err := app.readIDParam(r) + if err != nil || id < 1 { + switch { + case errors.Is(err, ErrParamInvalid): + err = fmt.Errorf("%w: ID", err) + app.badRequestResponse(w, r, err) + default: + app.notFoundResponse(w, r) + } + return + } + + project, err := app.models.Projects.Get(id) + if err != nil { + switch { + case errors.Is(err, da.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + if project.UserID != nil && !user.IsAnonymous() && *project.UserID != user.ID { + app.notPermittedResponse(w, r) + return + } + + err = app.writeJSON(w, http.StatusOK, envelope{"project": project}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateProjectHandler(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + + id, err := app.readIDParam(r) + if err != nil || id < 1 { + switch { + case errors.Is(err, ErrParamInvalid): + err = fmt.Errorf("%w: ID", err) + app.badRequestResponse(w, r, err) + default: + app.notFoundResponse(w, r) + } + return + } + + project, err := app.models.Projects.Get(id) + if err != nil { + switch { + case errors.Is(err, da.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + if project.UserID != nil && !user.IsAnonymous() && *project.UserID != user.ID { + app.notPermittedResponse(w, r) + return + } + + var p struct { + Name *string `json:"name"` + } + err = app.readJSON(w, r, &p) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + if p.Name != nil { + project.Name = *p.Name + } + + if err := app.models.Projects.Update(project); err != nil { + switch { + case errors.Is(err, da.ErrEditConflict): + app.editConflictResponse(w, r) + case errors.Is(err, da.ErrDuplicateProject): + app.failedValidationResponse(w, r, fmt.Errorf("name: a project with this name already exists")) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + err = app.writeJSON(w, http.StatusOK, envelope{"project": project}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteProjectHandler(w http.ResponseWriter, r *http.Request) { + user := app.contextGetUser(r) + + id, err := app.readIDParam(r) + if err != nil || id < 1 { + switch { + case errors.Is(err, ErrParamInvalid): + err = fmt.Errorf("%w: ID", err) + app.badRequestResponse(w, r, err) + default: + app.notFoundResponse(w, r) + } + return + } + + project, err := app.models.Projects.Get(id) + if err != nil { + switch { + case errors.Is(err, da.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + if project.UserID != nil && !user.IsAnonymous() && *project.UserID != user.ID { + app.notPermittedResponse(w, r) + return + } + + if err := app.models.Projects.Delete(id); err != nil { + switch { + case errors.Is(err, da.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + err = app.writeJSON(w, http.StatusOK, envelope{"message": "project deleted"}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/cmd/api/routes.go b/cmd/api/routes.go index 1f1e303..c8a4424 100644 --- a/cmd/api/routes.go +++ b/cmd/api/routes.go @@ -19,10 +19,18 @@ func (app *application) routes() http.Handler { router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) + router.HandlerFunc(http.MethodGet, "/v1/projects", app.listProjectsHandler) + router.HandlerFunc(http.MethodPost, "/v1/projects", app.createProjectHandler) + router.HandlerFunc(http.MethodGet, "/v1/projects/:id", app.getProjectHandler) + router.HandlerFunc(http.MethodPut, "/v1/projects/:id", app.updateProjectHandler) + router.HandlerFunc(http.MethodDelete, "/v1/projects/:id", app.deleteProjectHandler) + router.HandlerFunc(http.MethodGet, "/v1/deployments", app.listDeploymentsHandler) router.HandlerFunc(http.MethodPost, "/v1/deployments", app.createDeploymentHandler) router.HandlerFunc(http.MethodPost, "/v1/deployments/upload", app.uploadProjectHandler) - router.HandlerFunc(http.MethodPatch, "/v1/deployments/:id", app.cancelDeploymentHandler) + router.HandlerFunc(http.MethodGet, "/v1/deployments/:id", app.getDeploymentHandler) + router.HandlerFunc(http.MethodPut, "/v1/deployments/:id/stop", app.stopDeploymentHandler) + router.HandlerFunc(http.MethodPut, "/v1/deployments/:id/restart", app.restartDeploymentHandler) router.HandlerFunc(http.MethodGet, "/v1/deployments/:id/logs", app.streamLogsHandler) router.Handler(http.MethodGet, "/debug/vars", expvar.Handler()) diff --git a/deploy/ansible/dev/Vagrantfile b/deploy/ansible/dev/Vagrantfile index 4b1adde..834ae92 100644 --- a/deploy/ansible/dev/Vagrantfile +++ b/deploy/ansible/dev/Vagrantfile @@ -14,6 +14,9 @@ Vagrant.configure("2") do |config| if ENV["APPA_VAGRANT_PRIVATE_NETWORK"] == "1" config.vm.network :private_network, ip: "192.168.56.55" end + # Forward web ports so preflight checks and browser access work from the host + config.vm.network :forwarded_port, guest: 80, host: 8080, host_ip: "127.0.0.1" + config.vm.network :forwarded_port, guest: 443, host: 8443, host_ip: "127.0.0.1" # Disable the default /vagrant synced folder (avoids Guest Additions issues) config.vm.synced_folder ".", "/vagrant", disabled: true diff --git a/deploy/ansible/pyproject.toml b/deploy/ansible/pyproject.toml index 256cf60..4f1664a 100644 --- a/deploy/ansible/pyproject.toml +++ b/deploy/ansible/pyproject.toml @@ -12,5 +12,6 @@ dependencies = [ [dependency-groups] dev = [ + "ansible-dev-tools>=26.4.6", "ansible-lint>=26.4.0", ] diff --git a/deploy/ansible/roles/appa_stack/defaults/main.yml b/deploy/ansible/roles/appa_stack/defaults/main.yml index 6e57d97..90075f1 100644 --- a/deploy/ansible/roles/appa_stack/defaults/main.yml +++ b/deploy/ansible/roles/appa_stack/defaults/main.yml @@ -13,6 +13,6 @@ railpack_version: v0.23.0 # Derived defaults cors_origins: >- - {{ 'https://' + appa_instance_domain if appa_instance_domain else '*' }} + {{ 'https://' + appa_server_domain if appa_server_domain else '*' }} smtp_sender: >- - {{ 'Appa ' if appa_instance_domain else 'Appa ' }} + {{ 'Appa ' if appa_server_domain else 'Appa ' }} diff --git a/deploy/ansible/roles/appa_stack/tasks/main.yml b/deploy/ansible/roles/appa_stack/tasks/main.yml index 582f144..7c8d71e 100644 --- a/deploy/ansible/roles/appa_stack/tasks/main.yml +++ b/deploy/ansible/roles/appa_stack/tasks/main.yml @@ -27,7 +27,7 @@ - name: Generate random PostgreSQL password ansible.builtin.shell: - cmd: set -o pipefail && tr -dc A-Za-z0-9 0 + ignore_errors: true + +- name: Prune legacy network configuration + ansible.builtin.command: + cmd: docker network rm appa_net + register: appa_net_down + when: > + legacy_compose.stat.exists and + compose_down.rc is defined and compose_down.rc == 0 and + "Network appa_net" not in compose_down.stdout + failed_when: false + +- name: Create Docker Swarm network for Appa + ansible.builtin.command: + cmd: docker network create --driver overlay --attachable appa_net + register: appa_net_up + when: appa_net_down is skipped or appa_net_down is success + failed_when: + - appa_net_up.rc != 0 + - "'already exists' not in appa_net_up.stderr" + +- name: Deploy the Appa Data Stack + ansible.builtin.command: + cmd: docker stack deploy --detach --prune --quiet -c {{ appa_stack_dir }}/stack.data.yml appa_data + register: data_deploy + changed_when: data_deploy.stdout | length > 0 + +- name: Deploy the Appa Server Stack ansible.builtin.command: - cmd: docker compose -f {{ appa_stack_dir }}/compose.yml up -d - register: compose_up - changed_when: compose_up.stdout | length > 0 + cmd: docker stack deploy --detach --prune --quiet -c {{ appa_stack_dir }}/stack.base.yml appa + register: stack_deploy + changed_when: stack_deploy.stdout | length > 0 diff --git a/deploy/ansible/roles/appa_stack/templates/Caddyfile.j2 b/deploy/ansible/roles/appa_stack/templates/Caddyfile.j2 index 13dc049..f2ee567 100644 --- a/deploy/ansible/roles/appa_stack/templates/Caddyfile.j2 +++ b/deploy/ansible/roles/appa_stack/templates/Caddyfile.j2 @@ -1,4 +1,4 @@ -{% if appa_instance_domain %} +{% if appa_server_domain %} { admin 0.0.0.0:2019 debug @@ -7,7 +7,7 @@ {% endif %} } -{{ appa_instance_domain }}:80{% if cloudflare_token %}, {{ appa_instance_domain }}:443{% endif %} { +{{ appa_server_domain }}:80{% if cloudflare_token %}, {{ appa_server_domain }}:443{% endif %} { {% if cloudflare_token %} tls { dns cloudflare {$CLOUDFLARE_TOKEN} diff --git a/deploy/ansible/roles/appa_stack/templates/stack.base.yml.j2 b/deploy/ansible/roles/appa_stack/templates/stack.base.yml.j2 new file mode 100644 index 0000000..1252997 --- /dev/null +++ b/deploy/ansible/roles/appa_stack/templates/stack.base.yml.j2 @@ -0,0 +1,168 @@ +# ── Shared Anchors ───────────────────────────────────── +x-security: &security + cap_drop: + - ALL + cap_add: + - CHOWN + - SETGID + - SETUID + +x-dns: &dns + dns: + - 8.8.8.8 + - 8.8.4.4 + +x-logging: &logging + logging: + driver: "json-file" + options: + mode: "non-blocking" + max-buffer-size: "4m" + max-size: "10m" + max-file: "3" + +x-deploy: &deploy + update_config: + order: start-first + failure_action: rollback + delay: 10s + restart_policy: + condition: on-failure + delay: 15s + max_attempts: 3 + window: 120s + +x-env: &env + BUILDKIT_HOST: docker-container://buildkit + DOCKER_HOST: unix:///var/run/docker.sock + +networks: + appa_net: + driver: overlay + attachable: true + +volumes: + api_data: + caddy_data: + railpack_cache: + +services: + buildkit: + image: {{ buildkit_image }} + <<: *logging + privileged: true + networks: + - appa_net + volumes: + - /var/run/docker.sock:/var/run/docker.sock + deploy: + mode: replicated + replicas: 1 + resources: + limits: + memory: 2G + cpus: '1.0' + reservations: + memory: 512M + cpus: '0.5' + + api: + <<: [*dns, *security, *logging] + image: {{ appa_api_image }} + command: + - "-port=$PORT" + - "-env=$ENV" + - "-db-dsn=$APPA_DB_DSN" + - "-db-max-open-conns=$DB_MAX_OPEN_CONNS" + - "-db-max-idle-conns=$DB_MAX_IDLE_CONNS" + - "-db-max-idle-time=$DB_MAX_IDLE_TIME" + - "-limiter-rps=$LIMITER_RPS" + - "-limiter-burst=$LIMITER_BURST" + - "-limiter-enabled=$LIMITER_ENABLED" + - "-smtp-host=$SMTP_HOST" + - "-smtp-port=$SMTP_PORT" + - "-smtp-username=$SMTP_USERNAME" + - "-smtp-password=$SMTP_PASSWORD" + - "-smtp-sender=$SMTP_SENDER" + - "-cors-trusted-origins=$CORS_TRUSTED_ORIGINS" + depends_on: + - db + env_file: {{ appa_stack_dir }}/.env + environment: + <<: *env + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:$PORT"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - appa_net + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - api_data:/data + - railpack_cache:/tmp/railpack + - {{ appa_stack_dir }}/builds:/builds + deploy: + <<: *deploy + mode: replicated + replicas: 1 + resources: + limits: + memory: 512M + cpus: '0.5' + reservations: + memory: 256M + cpus: '0.25' + + caddy: + image: {{ caddy_image }} + <<: [*dns, *logging] + env_file: {{ appa_stack_dir }}/.env + ports: + - published: 80 + target: 80 + protocol: tcp + mode: host +{% if cloudflare_token %} + - published: 443 + target: 443 + protocol: tcp + mode: host +{% endif %} + volumes: + - {{ appa_stack_dir }}/Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + networks: + - appa_net + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.role == manager + resources: + limits: + memory: 128M + cpus: '0.25' + reservations: + memory: 64M + cpus: '0.1' + + ui: + image: {{ appa_ui_image }} + <<: *logging + depends_on: + - api + networks: + - appa_net + deploy: + mode: replicated + replicas: 1 + resources: + limits: + memory: 128M + cpus: '0.25' + reservations: + memory: 64M + cpus: '0.1' diff --git a/deploy/ansible/roles/appa_stack/templates/stack.data.yml.j2 b/deploy/ansible/roles/appa_stack/templates/stack.data.yml.j2 new file mode 100644 index 0000000..8b47d5b --- /dev/null +++ b/deploy/ansible/roles/appa_stack/templates/stack.data.yml.j2 @@ -0,0 +1,36 @@ +# Deployed once, independently +# docker stack deploy -c stack.data.yml appa_data + +networks: + appa_net: + external: true + +services: + db: + image: {{ postgres_image }} + env_file: {{ appa_stack_dir }}/.env + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - appa_net + ports: + - published: 5432 + target: 5432 + protocol: tcp + mode: host + volumes: + - {{ appa_stack_dir }}/data/postgres:/var/lib/postgresql + deploy: + mode: replicated + replicas: 1 + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 256M + cpus: '0.25' diff --git a/deploy/ansible/uv.lock b/deploy/ansible/uv.lock index b0ad501..d99efd7 100644 --- a/deploy/ansible/uv.lock +++ b/deploy/ansible/uv.lock @@ -22,6 +22,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/d4/ef0cb16757a0638de52b43a5e8edfd16e043b43b1db4ed53815aec67443e/ansible-14.0.0-py3-none-any.whl", hash = "sha256:937368ebb337973ddce0711b3c3e3b72ad9c27520ea03ad4628c08ca6c13adec", size = 49058588, upload-time = "2026-06-02T15:24:25.923Z" }, ] +[[package]] +name = "ansible-builder" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bindep" }, + { name = "jsonschema" }, + { name = "packaging" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/6b/0525894e5dd510c3a67da0b8819209333ca939cfa94b7f0d3ef041a628ec/ansible_builder-3.1.1.tar.gz", hash = "sha256:9d88bc15acc7d31056d0c51914a6102dac8e5ad73f9f2d35ba98378c89714ed2", size = 109392, upload-time = "2025-10-27T18:51:54.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/d0/e4a46f92084fcd6a94ca038645b1580316d3a6e0a8be28709bc4e00367cd/ansible_builder-3.1.1-py3-none-any.whl", hash = "sha256:a8246022edb92ca27ea95e87c7af30bcb2752f108dcc75fbf96e77196dff1072", size = 46517, upload-time = "2025-10-27T18:51:53.702Z" }, +] + [[package]] name = "ansible-compat" version = "26.3.0" @@ -54,6 +69,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/5c/75152a7ec51634bdc90b262a74cd6ca8b81cd6e264e19389c4c471e9f4df/ansible_core-2.21.0-py3-none-any.whl", hash = "sha256:fed7dd076365aad9ccf885c6b8f938e0fd508f5dcdffe5d2ca87cfb5fac47940", size = 2443954, upload-time = "2026-05-18T20:52:29.679Z" }, ] +[[package]] +name = "ansible-creator" +version = "26.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/72/466f53d93fd26a16c8020b5c53e1594757491ce75be58abffa48790a5266/ansible_creator-26.6.0.tar.gz", hash = "sha256:411cbadc595c18945e2c5e4f4bbdab82c6c2db524dbc816b22fb47157252622e", size = 9877774, upload-time = "2026-06-22T11:38:05.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/bc/c826d3317a478d86026391a237af36b240db3e95e045d0619f2042be5e9b/ansible_creator-26.6.0-py3-none-any.whl", hash = "sha256:0d7eba30927f65e2c27a1651e18a6e0602e2f68f8300a1c2ee05eccce0fe0893", size = 132845, upload-time = "2026-06-22T11:38:03.585Z" }, +] + +[[package]] +name = "ansible-dev-environment" +version = "26.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansible-builder" }, + { name = "bindep" }, + { name = "pyyaml" }, + { name = "subprocess-tee" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/62/989975b4d95d9d60152114051410871b91a8b377fc4f248abca0a2654db3/ansible_dev_environment-26.6.0.tar.gz", hash = "sha256:0853a31f506202e838a65cb4b6a2f0743868d06fbf287e59164611cbe0883bea", size = 239001, upload-time = "2026-06-22T12:22:37.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/82/01499a6963390d1c12353d973884507a8cdc4492f53b24286df96c1bd540/ansible_dev_environment-26.6.0-py3-none-any.whl", hash = "sha256:33f885e7c7996c6609a30f67ce466aae5c413f83f597f1363448afb107fb3719", size = 56702, upload-time = "2026-06-22T12:22:35.802Z" }, +] + +[[package]] +name = "ansible-dev-tools" +version = "26.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansible-builder" }, + { name = "ansible-compat" }, + { name = "ansible-creator" }, + { name = "ansible-dev-environment" }, + { name = "ansible-lint" }, + { name = "ansible-navigator" }, + { name = "ansible-sign" }, + { name = "molecule" }, + { name = "pytest-ansible" }, + { name = "setuptools" }, + { name = "tox-ansible" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/dd/331c75a7320ad8eba7274f4466843ecb46a445c6d271154eef851b15e9a3/ansible_dev_tools-26.4.6.tar.gz", hash = "sha256:353930fe0a82f745ec39ff3ba5d154a8042b15755143412327f421604580059d", size = 1054932, upload-time = "2026-04-30T03:55:34.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/87/9a6dae450dc5fa182832fa1166f05e5b781989c95f8c685bb82ece98f4b2/ansible_dev_tools-26.4.6-py3-none-any.whl", hash = "sha256:6610c59cd85f150ada1fe954200c73fd0baa573efb493cbaa09d8a53f6abfcde", size = 50110, upload-time = "2026-04-30T03:55:33.414Z" }, +] + [[package]] name = "ansible-lint" version = "26.4.0" @@ -82,6 +147,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/97/803cde1dba6c5cc24f1401b37df8404a2793bd26dc3f11c0a9722109b859/ansible_lint-26.4.0-py3-none-any.whl", hash = "sha256:f33c4823544a5a8e5e36614866e111d1a929eeffee5e84481ede10d524a9d6d6", size = 330939, upload-time = "2026-04-01T14:41:00.083Z" }, ] +[[package]] +name = "ansible-navigator" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansible-builder" }, + { name = "ansible-lint" }, + { name = "ansible-runner" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "onigurumacffi" }, + { name = "pyyaml" }, + { name = "setuptools" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/75/1d3a5f9d20ef9fa12f6d0a83e8055f38e586944e260ff40c9b157f195e77/ansible_navigator-26.4.0.tar.gz", hash = "sha256:1690c55b236796ddbcc001ba3a0263c2f2be50c0840f21f83fd7d0fade49f054", size = 394410, upload-time = "2026-04-06T09:02:09.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/b6/4d8a944e3db20d745bb96227f366d507709ed7095bffd15a5104d2a52d0b/ansible_navigator-26.4.0-py3-none-any.whl", hash = "sha256:62bdf3fc1b3c06a11201e915bbd98dbe7469b1f1f4bfc50c638aa92485368ff1", size = 304806, upload-time = "2026-04-06T09:02:08.105Z" }, +] + +[[package]] +name = "ansible-runner" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pexpect" }, + { name = "python-daemon" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/4f/62222b42b52cc771db51ccc77ffac19cc5f0196ff61de7c93585845a819b/ansible_runner-2.4.3.tar.gz", hash = "sha256:5f3025529bb968fdc3b627457dd8d418dbf9d53bc73735d50e69c28544d53031", size = 154148, upload-time = "2026-03-16T18:42:14.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/89/fa7f81db6134caa455c9351373ee934b5fdeed212eb939d6c42d80d683d7/ansible_runner-2.4.3-py3-none-any.whl", hash = "sha256:cdac6daa151a50084ffda710e769db23fa975fc0507796191d7708831b286e37", size = 80257, upload-time = "2026-03-16T18:42:13.72Z" }, +] + +[[package]] +name = "ansible-sign" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "python-gnupg" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/30/bbd2646e67625151e60ed4f3411252df48a0af23b44a1bcd1228ca98afc3/ansible_sign-0.1.5.tar.gz", hash = "sha256:d1c9b5ef4329501615b18fe2bf035f63a429f7c05e653d32ac59fc01892eaf74", size = 106439, upload-time = "2026-02-25T11:40:28.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/44/d277d8b2d42b301e134fdc5e324147096af594fa759e1301163e410e4f17/ansible_sign-0.1.5-py3-none-any.whl", hash = "sha256:ef3f263dc5e55bba53c1c0548ee8858715c088bb8e93bdec7076b517905a03af", size = 17093, upload-time = "2026-02-25T11:40:27.144Z" }, +] + [[package]] name = "appa-ansible" version = "0.1.0" @@ -94,6 +207,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "ansible-dev-tools" }, { name = "ansible-lint" }, ] @@ -105,7 +219,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "ansible-lint", specifier = ">=26.4.0" }] +dev = [ + { name = "ansible-dev-tools", specifier = ">=26.4.6" }, + { name = "ansible-lint", specifier = ">=26.4.0" }, +] [[package]] name = "attrs" @@ -116,6 +233,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "bindep" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distro" }, + { name = "packaging" }, + { name = "parsley" }, + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/c2/a549e0286fd2711e401dc240184fb89c06e2ff1642fb7c6cac7a95fe0c8c/bindep-2.14.0.tar.gz", hash = "sha256:0c804c7e48dd17db24cb39121ade7171f684fb463c8aa01d0338cd11d254364a", size = 44234, upload-time = "2026-03-19T14:58:49.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/50/b102034fbff7f86dc1120ba72309c12a9a4937e02b941e7c7a301879dc0f/bindep-2.14.0-py3-none-any.whl", hash = "sha256:235181c6f79f3a3edb831a6ae55f61185bc1de0f7761904d906507e9a5d801c9", size = 35171, upload-time = "2026-03-19T14:58:48.621Z" }, +] + [[package]] name = "black" version = "26.5.1" @@ -157,6 +289,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -370,6 +511,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -405,6 +555,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/67/aecd1d435dbbdcea21a197d708e9ff0bcc7306c2847c6c87cc1a91e2cca4/enrich-1.2.7-py3-none-any.whl", hash = "sha256:f29b2c8c124b4dbd7c975ab5c3568f6c7a47938ea3b7d2106c8a3bd346545e4f", size = 8717, upload-time = "2022-01-10T15:30:32.723Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "filelock" version = "3.29.1" @@ -423,6 +582,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -462,6 +630,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "lockfile" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/47/72cb04a58a35ec495f96984dddb48232b551aafb95bde614605b754fe6f7/lockfile-0.12.2.tar.gz", hash = "sha256:6aed02de03cba24efabcd600b30540140634fc06cfa603822d508d5361e9f799", size = 20874, upload-time = "2015-11-25T18:29:58.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/22/9460e311f340cb62d26a38c419b1381b8593b0bb6b5d1f056938b086d362/lockfile-0.12.2-py2.py3-none-any.whl", hash = "sha256:6c3cb24f344923d30b2785d5ad75182c8ea7ac1b6171b08657258ec7429d50fa", size = 13564, upload-time = "2015-11-25T18:29:51.462Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -596,6 +773,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "onigurumacffi" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/e9/fab7102ca135470374f81e74e9944c9f58f2bae905df2c045ac0f30838fd/onigurumacffi-1.5.0.tar.gz", hash = "sha256:d4fa9bee44a6d38a98b2237c67d9acdf27ed0c32d6c218125fe46681a5edea4d", size = 5582, upload-time = "2026-01-25T23:08:44.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/f9/2717b5a52032b74e0f53022941baf24dc50ecc99fb31da5621b88882ce64/onigurumacffi-1.5.0-cp310-abi3-macosx_15_0_arm64.whl", hash = "sha256:6dda67bcbfe5ec8f819506befaa8542f7bc4dd89304dfae31810bf4f15dc2205", size = 224764, upload-time = "2026-01-25T23:12:43.084Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c1/e211c1f803c4b1315a8e2b017c7257ce5a7b11543ad63f3b7d19696c845f/onigurumacffi-1.5.0-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:3aabcd932605f0a385c612ff22daa97acfa57e78add53bb4a173f281a3b9cf1f", size = 235989, upload-time = "2026-01-25T23:12:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/21/16/df7857783279384c910ac6c5272699a226fcec9dc8969b4fe230069d2f99/onigurumacffi-1.5.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d2e195b0255f88616af19021ebd6344a00d5a0df01a78c8c43729cf0b9c9fb9c", size = 605635, upload-time = "2026-01-25T23:12:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7c/fda92bcc28c6ef05d6ce4cfd0f85e7a70e6fa9ea3a23fb7fe1b4cf6ef092/onigurumacffi-1.5.0-cp310-abi3-win32.whl", hash = "sha256:995a3a0d932df3995f6e9b081c4c5881ed6bc19c53d9c24d64a51eb21f46989c", size = 178808, upload-time = "2026-01-25T23:12:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/a3/67/6461766dc7e7baea1b02df2c081ac7efcde3edf9cb74a80f570a7dc0566b/onigurumacffi-1.5.0-cp310-abi3-win_amd64.whl", hash = "sha256:862795245ac165913f146e81d18e5419c91f98f50a5eccba02e1ba6d781e8e26", size = 193354, upload-time = "2026-01-25T23:12:49.074Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -605,6 +798,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "parsley" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/52/cac2f9e78c26cff8bb518bdb4f2b5a0c7058dec7a62087ed48fe87478ef0/Parsley-1.3.tar.gz", hash = "sha256:9444278d47161d5f2be76a767809a3cbe6db4db822f46a4fd7481d4057208d41", size = 99616, upload-time = "2015-09-09T02:53:04.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/d6/4fed8d65e28a970e1c5cb33ce9c7e22e3de745e1b2ae37af051ef16aea3b/Parsley-1.3-py2.py3-none-any.whl", hash = "sha256:c3bc417b8c7e3a96c87c0f2f751bfd784ed5156ffccebe2f84330df5685f8dc3", size = 88932, upload-time = "2015-09-09T02:53:09.977Z" }, +] + [[package]] name = "pathspec" version = "1.0.4" @@ -614,6 +816,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "pbr" +version = "7.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ab/1de9a4f730edde1bdbbc2b8d19f8fa326f036b4f18b2f72cfbea7dc53c26/pbr-7.0.3.tar.gz", hash = "sha256:b46004ec30a5324672683ec848aed9e8fc500b0d261d40a3229c2d2bbfcedc29", size = 135625, upload-time = "2025-11-03T17:04:56.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/db/61efa0d08a99f897ef98256b03e563092d36cc38dc4ebe4a85020fe40b31/pbr-7.0.3-py2.py3-none-any.whl", hash = "sha256:ff223894eb1cd271a98076b13d3badff3bb36c424074d26334cd25aebeecea6b", size = 131898, upload-time = "2025-11-03T17:04:54.875Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + [[package]] name = "platformdirs" version = "4.10.0" @@ -632,6 +858,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -650,6 +885,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyproject-api" +version = "1.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/62/0fe346fe380b1aafaf819c8cb195d3241bb4f355f908e6339814131a830b/pyproject_api-1.10.1.tar.gz", hash = "sha256:c2b2726bd7aa9217b6c50b621fef5b2ae5def4d55b779c9e0694c15e0a8517ba", size = 23477, upload-time = "2026-05-28T14:22:14.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/d7/29e1e5e882f79133631f7bcace42d23db493f616463c157a1ab614bf69dd/pyproject_api-1.10.1-py3-none-any.whl", hash = "sha256:fa9e6f66c35b5017e909825d8f2b5d5482ea699d7be809d21c03bd1f7317f36a", size = 12992, upload-time = "2026-05-28T14:22:12.711Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-ansible" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansible-compat" }, + { name = "ansible-core" }, + { name = "cffi" }, + { name = "packaging" }, + { name = "pytest" }, + { name = "pytest-xdist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/c1/29eb44787b9a76227077fbbf3fd147d873a61235ffad1afdd9688c16f3c1/pytest_ansible-26.4.0.tar.gz", hash = "sha256:50c2c5f05472e5a41a8d9b29c38e98292c1605f57a70954fc20b51b1f5cabb82", size = 192858, upload-time = "2026-04-01T14:39:38.079Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/96/e6f7b885ef8cf94b399a15485b972d4e1fb16a087a2914e9ad192e1eb7d2/pytest_ansible-26.4.0-py3-none-any.whl", hash = "sha256:146c3adbae17d5fcfa8bc60b8b92301c1b613d6814d42061fbc26d6e2ddab1dd", size = 31426, upload-time = "2026-04-01T14:39:36.427Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-daemon" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lockfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/37/4f10e37bdabc058a32989da2daf29e57dc59dbc5395497f3d36d5f5e2694/python_daemon-3.1.2.tar.gz", hash = "sha256:f7b04335adc473de877f5117e26d5f1142f4c9f7cd765408f0877757be5afbf4", size = 71576, upload-time = "2024-12-03T08:41:07.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/3c/b88167e2d6785c0e781ee5d498b07472aeb9b6765da3b19e7cc9e0813841/python_daemon-3.1.2-py3-none-any.whl", hash = "sha256:b906833cef63502994ad48e2eab213259ed9bb18d54fa8774dcba2ff7864cec6", size = 30872, upload-time = "2024-12-03T08:41:03.322Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + +[[package]] +name = "python-gnupg" +version = "0.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/2c/6cd2c7cff4bdbb434be5429ef6b8e96ee6b50155551361f30a1bb2ea3c1d/python_gnupg-0.5.6.tar.gz", hash = "sha256:5743e96212d38923fc19083812dc127907e44dbd3bcf0db4d657e291d3c21eac", size = 66825, upload-time = "2025-12-31T17:19:33.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/ab/0ea9de971caf3cd2e268d2b05dfe9883b21cfe686a59249bd2dccb4bae33/python_gnupg-0.5.6-py2.py3-none-any.whl", hash = "sha256:b5050a55663d8ab9fcc8d97556d229af337a87a3ebebd7054cbd8b7e2043394a", size = 22082, upload-time = "2025-12-31T17:16:22.743Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -961,6 +1289,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/a7/ef33f8e5818ed6333cf5cb6f85f4f604e13d37d1bae4671e10d6f627f6d6/selinux-0.3.0-py2.py3-none-any.whl", hash = "sha256:ecf7add45c939e9dda682c390a2cd0a845c94a4793a2cce9e8870d4ee9501f99", size = 4160, upload-time = "2022-12-24T16:42:11.745Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "subprocess-tee" version = "0.4.2" @@ -970,6 +1307,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/ab/e3a3be062cd544b2803760ff707dee38f0b1cb5685b2446de0ec19be28d9/subprocess_tee-0.4.2-py3-none-any.whl", hash = "sha256:21942e976715af4a19a526918adb03a8a27a8edab959f2d075b777e3d78f532d", size = 5249, upload-time = "2024-06-17T19:51:15.949Z" }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "tox" +version = "4.55.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "python-discovery" }, + { name = "tomli-w" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/5b/4f09156a3f7bf3c4fa23212717f097c59126d81e2c557e6fd872a62db38a/tox-4.55.1.tar.gz", hash = "sha256:0678fbf26dd5b559b1ef128fa4388325920219322ebc8cc5f3497627c00f4472", size = 280676, upload-time = "2026-06-03T20:01:03.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/fd/394f00f3d3e23d87eb7b20276d88fe835e48780d3eb30e6f362428bb80c8/tox-4.55.1-py3-none-any.whl", hash = "sha256:e2084be6dfdef96ba1bed4948e6a1f73613d6952e1477be5dca45653d4c053c8", size = 215360, upload-time = "2026-06-03T20:01:01.967Z" }, +] + +[[package]] +name = "tox-ansible" +version = "26.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "pytest-ansible" }, + { name = "pytest-xdist" }, + { name = "pyyaml" }, + { name = "tox" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/d0/9f72f3ab27dcb214e653ec2fad45a4627dca14f8b812e006d4d9b3e414fd/tox_ansible-26.6.0.tar.gz", hash = "sha256:19e0dfe01ef0b87d427179eca7bc7b3ca4c3cc47949aa248bd45739bf5417e71", size = 214999, upload-time = "2026-06-12T07:01:07.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/5d/26e09021f84e4bf82a8f977d6ed37a9e09dc6345ed81da569b618120ee18/tox_ansible-26.6.0-py3-none-any.whl", hash = "sha256:aee849c50c26c774802b22aaa66823552fbe4474d1b9385543fdf60fe6f673e9", size = 11536, upload-time = "2026-06-12T07:01:05.779Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -979,6 +1362,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -988,6 +1380,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + [[package]] name = "wcmatch" version = "10.1" diff --git a/docs/architecture.md b/docs/architecture.md index 5ab32f3..30d13f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,10 +17,9 @@ and [cas-sync.md](./cas-sync.md). | --- | --- | | Appa CLI | Local operator command-line tool, binary `appa`. | | Appa Server | Remote API, dashboard, and deployment runtime on a VPS. | -| Appa Instance | One Appa Server installation managed by the CLI. | | Appa Stack | Server-side services: API, UI, PostgreSQL, BuildKit, Caddy. | -| Instance config | Local CLI configuration for one instance (SSH target, settings, operator). | -| Project | Local CLI configuration mapping a source directory to a target instance. | +| Server config | Local CLI configuration for one server (SSH target, settings, operator). | +| Project | Local CLI configuration mapping a source directory to a target server. | | Deploy user | Non-privileged system user created during setup for CLI automation (rsync, API calls). | | Operator user | Optional sudo user created during setup for manual SSH access. | | Deployment | One submitted source package and its lifecycle state. | @@ -35,11 +34,11 @@ and [cas-sync.md](./cas-sync.md). ``` Operator Machine - └── Appa CLI (instance profiles, preflight, setup/apply/status) + └── Appa CLI (server profiles, preflight, setup/apply/status) │ SSH + Ansible ▼ -Remote VPS / Appa Instance - └── Appa Stack (Docker Compose services + generated config) +Remote VPS / Appa Server + └── Appa Stack (Docker Stack services + generated config) │ ▼ Browser → [ React Dashboard ] → [ Caddy Gateway ] @@ -59,11 +58,11 @@ tokens. ## Core Flows -### Operator Provisions an Instance +### Operator Provisions a Server 1. Install CLI (`appa.theolujay.dev/install.sh`). -2. `appa instance init personal` → creates `~/.appa/instances/personal/config.toml`. -3. `appa instance set-host personal root@203.0.113.10` → SSH target. +2. `appa server init personal` → creates `~/.appa/servers/personal/config.toml`. +3. `appa server set-host personal root@203.0.113.10` → SSH target. 4. `appa preflight personal` → validates SSH, OS, ports, DNS, inputs. 5. `appa setup personal` → runs Ansible (security-hardening then deploy-stack). 6. Ansible installs Docker, writes env/config/templates, starts Appa Stack. @@ -85,14 +84,16 @@ Progressive configuration: SSH target first, then domain, Cloudflare, SMTP, etc. ### Operator Deploys a Project (CLI) -1. `appa deploy init --target ` creates project metadata +1. `appa project init --target ` creates project metadata in `~/.appa/projects//config.toml`. -2. `appa deploy ` loads the project and target instance profiles. +2. `appa deploy ` loads the project and target server profiles. 3. CLI rsyncs the source directory over SSH to `/opt/appa/builds//` - on the instance using the deploy user's credentials. -4. CLI sends an API request to the instance with - `{"source_path": "/builds//"}`. -5. API pipeline reads source from the local path and proceeds through + on the server using the deploy user's credentials. +4. CLI calls the Appa API to **auto-create the project** on the server + (if it doesn't exist yet) and retrieves the project ID. +5. CLI sends an API request with + `{"source_path": "/builds//", "project_id": }`. +6. API pipeline reads source from the local path and proceeds through Railpack → BuildKit → container start (same flow as Git deploys). ### Logs Streaming @@ -126,12 +127,12 @@ Progressive configuration: SSH target first, then domain, Cloudflare, SMTP, etc. 8. Logs persisted before WebSocket publication. 9. Uploaded archives extracted into isolated per-upload directories. 10. Platform and user workloads share a network, not a process. -11. CLI ships source to the instance via rsync; the API remains the sole +11. CLI ships source to the server via rsync; the API remains the sole deployment authority — all builds, container lifecycle, and route management happen server-side. 12. `setup`/`apply` are idempotent. 13. Operator secrets are never logged. -14. Local instance profiles and credentials are encrypted using Ansible Vault (planned). +14. Local server profiles and credentials are encrypted using Ansible Vault (planned). ## Failure Model @@ -155,9 +156,9 @@ Progressive configuration: SSH target first, then domain, Cloudflare, SMTP, etc. - **WebSocket hub pattern** — Single goroutine for registration/broadcast; DB as durable store. - **Wildcard TLS via DNS-01** — HTTP-01 doesn't support wildcards; Cloudflare + caddy-dns plugin. - **CLI-managed provisioning** — CLI owns profiles/preflight/Ansible gen; Ansible owns host mutations; API owns deployments. -- **Ansible Vault for Local Secrets** (Planned) — Instance profiles and operator configuration (including passwords, tokens, and SSH keys) are encrypted on the operator's disk using Ansible Vault to protect secrets at rest. +- **Ansible Vault for Local Secrets** (Planned) — Server profiles and operator configuration (including passwords, tokens, and SSH keys) are encrypted on the operator's disk using Ansible Vault to protect secrets at rest. - **rsync for source shipping** — `appa deploy` uses rsync over SSH to transfer - source files to the instance. It relies on system `rsync` on both ends but + source files to the server. It relies on system `rsync` on both ends but requires no new infrastructure. A pure-Go content-addressable replacement is researched in [cas-sync.md](./cas-sync.md) for future consideration. - **Content-Addressable Sync** (Future) — A CAS engine over SSH using SHA-256 diff --git a/docs/user-guide.md b/docs/user-guide.md index 6fb542e..511b4c6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1,10 +1,10 @@ -# Appa User Guide +# User Guide ## What is Appa? Appa is a self-hosted deployment platform you control from your terminal. You provision a VPS once with the CLI, then deploy projects with a single -command — no Dockerfiles, no web server config, no manual routing. +command: no Dockerfiles, no web server config, no manual routing. --- @@ -12,11 +12,12 @@ command — no Dockerfiles, no web server config, no manual routing. 1. [Prerequisites](#prerequisites) 2. [CLI Installation](#cli-installation) -3. [Setting Up an Instance](#setting-up-an-instance) +3. [Setting Up a Server](#setting-up-a-server) 4. [Configuration Reference](#configuration-reference) -5. [Instance Operations](#instance-operations) +5. [Server Operations](#server-operations) 6. [Deploying Projects](#deploying-projects) -7. [CLI Command Reference](#cli-command-reference) +7. [Project Lifecycle & Environment Variables](#project-lifecycle--environment-variables) +8. [CLI Command Reference](#cli-command-reference) --- @@ -32,6 +33,17 @@ command — no Dockerfiles, no web server config, no manual routing. ## CLI Installation +### Install script (recommended) + +```bash +curl -fsSL https://appa.theolujay.dev/install.sh | sh +``` + +The script downloads the latest binary, verifies its checksum, and installs it +to a directory in your `$PATH`. It also automatically provisions **uv** and +**Ansible** in an isolated venv under `~/.appa/ansible/`: no system-wide +Python dependencies required. + ### Build from source ```bash @@ -43,15 +55,15 @@ make build/cli --- -## Setting Up an Instance +## Setting Up a Server -An "instance" is a single Appa Server installation on a VPS. The CLI stores -its configuration locally in `~/.appa/instances//config.toml`. +A "server" is a single Appa Server instance on a VPS. The CLI stores +its configuration locally in `~/.appa/servers//config.toml`. -### 1. Create an instance config +### 1. Create a server config ```bash -appa instance init my-server +appa server init my-server ``` This creates the config and records your current OS username as the @@ -59,13 +71,13 @@ This creates the config and records your current OS username as the it with `--op-name`: ```bash -appa instance init my-server --op-name jane +appa server init my-server --op-name jane ``` ### 2. Set the SSH target ```bash -appa instance set-host my-server root@203.0.113.10 -i ~/.ssh/id_ed25519 +appa server set-host my-server root@203.0.113.10 -i ~/.ssh/id_ed25519 ``` The target format is `user@host` or `user@host:port`. The CLI tests the @@ -74,8 +86,9 @@ connection before saving. Flags: | Flag | Description | -|---|---| +|---|---|---| | `-i`, `--identity-file` | Path to your SSH private key | +| `--port` | API port override (e.g. `8080` for Vagrant forwarded port; default 80) | | `--skip-verify` | Skip host key verification (e.g. for ephemeral VMs) | ### 3. Run preflight checks @@ -85,7 +98,13 @@ appa preflight my-server ``` Preflight validates SSH connectivity, OS compatibility, required ports, -and checks for existing Docker installations. +and checks for existing Docker installations. Use `--no-tty` to run in +non-interactive mode (e.g. CI pipelines). + +| Flag | Description | +|---|---| +| `--no-tty` | Run in non-interactive mode (plain text output) | +| `--skip-verify` | Skip SSH host key verification | ### 4. Provision the server @@ -101,7 +120,7 @@ This is the big one. Setup does all of the following automatically: `operator` user (your username, for manual SSH), each with your SSH key. 3. **Installs Docker** — Docker Engine and Compose plugin. 4. **Deploys the Appa Stack** — pulls container images for the API, database, - BuildKit, Caddy, and the web UI, then starts everything via Docker Compose. + BuildKit, Caddy, and the web UI, then starts everything via Docker Stack. 5. **Waits for health** — polls the API health endpoint until it responds. After setup, subsequent SSH operations use the `deploy` user (never `root`). @@ -125,7 +144,7 @@ account, and you're ready to deploy apps through the UI. ## Configuration Reference -### Instance config (`~/.appa/instances//config.toml`) +### Server config (`~/.appa/servers//config.toml`) ```toml name = "my-server" @@ -136,12 +155,12 @@ ssh_identity_file = "/home/you/.ssh/id_ed25519" skip_ssh_verify = false operator_user_name = "jane" setup_done = true -base_api_url = "http://203.0.113.10" +api_base_url = "http://203.0.113.10" ``` | Field | Description | |---|---| -| `name` | Instance name (matches the directory) | +| `name` | Server name (matches the directory) | | `ssh_host` | VPS IP or hostname | | `ssh_user` | SSH user — `root` before setup, `deploy` after | | `ssh_port` | SSH port (default 22) | @@ -152,16 +171,19 @@ base_api_url = "http://203.0.113.10" | `cloudflare_token` | Cloudflare API token (optional — for automatic wildcard TLS) | | `smtp_*` | SMTP credentials (optional — for email notifications) | | `setup_done` | Whether `appa setup` has completed | -| `base_api_url` | API base URL (set automatically after setup) | +| `api_base_url` | API base URL (set automatically after setup) | +| `api_port` | API port override (e.g. `8080` for Vagrant forwarded port; default 80) | -The quickest way to edit an instance config is: +The quickest way to edit a server config is: ```bash -appa instance edit my-server +appa server edit my-server ``` This opens the TOML file in your `$EDITOR` and validates it on save. If -validation fails, you can re-edit or abort (changes are reverted). +validation fails, you can re-edit or abort (changes are reverted). If you +rename the server in the editor, the config directory is renamed +automatically. ### Project config (`~/.appa/projects//config.toml`) @@ -175,7 +197,7 @@ target = "my-server" |---|---| | `name` | Project name (matches the directory) | | `source` | Absolute path to the project's source directory | -| `target` | Target instance name (must match an existing instance) | +| `target` | Target server name (must match an existing server) | Edit with: @@ -185,9 +207,9 @@ appa project edit my-app --- -## Instance Operations +## Server Operations -Once your instance is set up, you can manage it remotely: +Once your server is set up, you can manage it remotely: ### Check status @@ -219,7 +241,7 @@ appa logs my-server -n 100 ### Apply configuration changes -After editing an instance config (e.g. changing the domain or SMTP settings), +After editing a server config (e.g. changing the domain or SMTP settings), re-apply them: ```bash @@ -257,7 +279,7 @@ appa upgrade my-server --version v0.2.0 ## Deploying Projects -Projects let you deploy source code from your local machine to an instance. +Projects let you deploy source code from your local machine to a server. ### 1. Create a project @@ -278,7 +300,7 @@ appa project init /home/you/code/my-app --target my-server appa project edit my-app ``` -Lets you change the source path or target instance. +Lets you change the source path or target server. ### 3. Deploy @@ -289,11 +311,13 @@ appa deploy my-app What happens under the hood: 1. The CLI **rsyncs** your source directory to `/opt/appa/builds/my-app/` on - the target instance using SSH. -2. It calls the Appa API with the server-side path. -3. The API runs the pipeline: Railpack detects the runtime, BuildKit builds + the target server using SSH. +2. The CLI calls the API to **auto-create the project** (if it doesn't already + exist on the server) and retrieves the project ID. +3. It triggers a deployment with the server-side path and project ID. +4. The API runs the pipeline: Railpack detects the runtime, BuildKit builds the image, Docker starts the container, and Caddy registers a route. -4. The CLI prints the deployment ID, status, and creation time. +5. The CLI prints the deployment ID, status, and creation time. Hide rsync transfer progress: @@ -303,11 +327,54 @@ appa deploy my-app --quiet --- +## Project Lifecycle & Environment Variables + +Once a project has been deployed, you can manage its lifecycle and +configuration through the CLI. + +### View project logs + +```bash +appa project logs my-app +``` + +Opens a WebSocket-powered TUI that streams build and runtime logs from the +latest deployment. Supports scrolling, follow mode, and keyboard navigation. + +### Stop a project + +```bash +appa project stop my-app +``` + +Stops the running container or cancels a pending deployment. + +### Restart a project + +```bash +appa project restart my-app +``` + +Stops the current deployment and triggers a new build and deploy. + +### Manage environment variables + +```bash +appa project env set my-app KEY=value +appa project env get my-app +appa project env unset my-app KEY +``` + +Environment variables are stored on the server and merged into the build +step alongside any deployment-specific variables. + +--- + ## CLI Command Reference -### `appa instance init ` +### `appa server init ` -Create a new instance config. +Create a new server config. | Flag | Description | |---|---| @@ -315,42 +382,45 @@ Create a new instance config. --- -### `appa instance edit ` +### `appa server edit ` -Open instance config in `$EDITOR` with validation on save. +Open server config in `$EDITOR` with validation on save. Renaming the +server in the editor renames the config directory automatically. --- -### `appa instance set-host ` +### `appa server set-host ` -Set the SSH target for an instance. +Set the SSH target for a server. | Flag | Description | |---|---| | `-i`, `--identity-file` | Path to SSH private key | +| `--port` | API port override (e.g. `8080` for Vagrant forwarded port; default 80) | | `--skip-verify` | Skip host key verification | --- -### `appa instance list` +### `appa server ls` -List all configured instances. +List all configured servers. --- ### `appa preflight ` -Run preflight checks on a target instance. +Run preflight checks on a target server. | Flag | Description | |---|---| +| `--no-tty` | Run in non-interactive mode (plain text output) | | `--skip-verify` | Skip SSH host key verification | --- ### `appa setup ` -First-time provisioning of an Appa instance. +First-time provisioning of an Appa server. | Flag | Description | |---|---| @@ -376,7 +446,7 @@ Re-apply configuration changes idempotently. ### `appa status ` -Show instance health and service status. +Show server health and service status. --- @@ -417,7 +487,7 @@ Create a new project. | Flag | Description | |---|---| -| `-t`, `--target` | Target instance name | +| `-t`, `--target` | Target server name | | `-n`, `--name` | Project name (inferred from source if not specified) | --- @@ -428,6 +498,42 @@ Open project config in `$EDITOR` with validation on save. --- +### `appa project logs ` + +Stream WebSocket logs from the latest deployment in a TUI viewer. + +--- + +### `appa project stop ` + +Stop the running container or cancel a pending deployment. + +--- + +### `appa project restart ` + +Stop the current deployment and trigger a new build. + +--- + +### `appa project env set ` + +Set an environment variable for a project. + +--- + +### `appa project env get ` + +List all environment variables for a project. + +--- + +### `appa project env unset ` + +Remove an environment variable from a project. + +--- + ### `appa deploy ` Deploy an already initialized project. diff --git a/go.mod b/go.mod index 69e796a..81721e1 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,11 @@ module github.com/theolujay/appa go 1.26.2 require ( + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.4 github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c + github.com/charmbracelet/harmonica v0.2.0 + github.com/charmbracelet/huh v1.0.0 github.com/containerd/errdefs v1.0.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 @@ -16,23 +20,52 @@ require ( github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce github.com/wneessen/go-mail v0.7.3 golang.org/x/crypto v0.51.0 + golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect @@ -41,8 +74,7 @@ require ( golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/tools v0.44.0 // indirect honnef.co/go/tools v0.7.0 // indirect ) diff --git a/go.sum b/go.sum index 45f6960..67cf6d7 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,66 @@ +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +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/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +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/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +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/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -15,6 +69,10 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -36,18 +94,36 @@ github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -59,6 +135,8 @@ github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9 github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0= github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk= +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= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= @@ -76,14 +154,18 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= diff --git a/install.sh b/install.sh index 8621a1f..4f99cdd 100755 --- a/install.sh +++ b/install.sh @@ -5,17 +5,17 @@ REPO_OWNER="theolujay" REPO_NAME="appa" BOLD="$(tput bold 2>/dev/null || printf '')" -GREY="$(tput setaf 0 2>/dev/null || printf '')" -RED="$(tput setaf 1 2>/dev/null || printf '')" -GREEN="$(tput setaf 2 2>/dev/null || printf '')" -YELLOW="$(tput setaf 3 2>/dev/null || printf '')" -BLUE="$(tput setaf 4 2>/dev/null || printf '')" -NO_COLOR="$(tput sgr0 2>/dev/null || printf '')" +PURPLE="$(tput setaf 99 2>/dev/null || printf '\033[38;2;125;86;244m')" +GREEN="$(tput setaf 78 2>/dev/null || printf '\033[38;2;67;191;109m')" +RED="$(tput setaf 203 2>/dev/null || printf '\033[38;2;255;85;85m')" +YELLOW="$(tput setaf 228 2>/dev/null || printf '\033[38;2;241;250;140m')" +GRAY="$(tput setaf 61 2>/dev/null || printf '\033[38;2;98;114;164m')" +NO_COLOR="$(tput sgr0 2>/dev/null || printf '\033[0m')" CURL_RETRY_OPTS="--retry 3 --retry-all-errors --retry-delay 2" -info() { printf '%s\n' "${BOLD}${GREY}>${NO_COLOR} $*"; } -warn() { printf '%s\n' "${YELLOW}! $*${NO_COLOR}"; } +info() { printf '%s\n' "${GRAY}> $*${NO_COLOR}"; } +warn() { printf '%s\n' "${BOLD}${YELLOW}! $*${NO_COLOR}"; } error() { printf '%s\n' "${RED}x $*${NO_COLOR}" >&2; } completed() { printf '%s\n' "${GREEN}✓${NO_COLOR} $*"; } @@ -50,7 +50,7 @@ spin_start() { i=1 while true; do frame=$(printf '%s' "$frames" | cut -d' ' -f$i) - printf '\r %s%s%s %s' "${BLUE}" "$frame" "${NO_COLOR}" "$msg" + printf '\r %s %s' "$frame" "$msg" i=$(( (i % 10) + 1 )) sleep 0.08 done @@ -200,7 +200,7 @@ printf '\n' # --- Confirm --- if [ -t 0 ] && [ -z "${FORCE}" ]; then - printf '%s' "${YELLOW}?${NO_COLOR} Install ${REPO_NAME} ${GREEN}${VERSION}${NO_COLOR} to ${BOLD}${GREEN}${BIN_DIR}${NO_COLOR}? [y/N] " + printf '%s' "${PURPLE}?${NO_COLOR} Install ${REPO_NAME} ${GREEN}${VERSION}${NO_COLOR} to ${BOLD}${GREEN}${BIN_DIR}${NO_COLOR}? [y/N] " read -r yn /dev/null + spin_stop + + spin_start "Installing Ansible..." + uv pip install 'ansible>=14.0.0' 2>/dev/null + spin_stop + completed "Ansible installed" + + info " Appa will manage this venv at ~/.appa/ansible/.venv/" + info " To remove it: rm -rf ~/.appa/ansible/.venv" +fi + # --- Done --- printf '\n' -printf '%s' "${BLUE}" +printf '%s' "${PURPLE}" cat << 'EOF' _|_| diff --git a/internal/cli/ansible/ansible.go b/internal/cli/ansible/ansible.go index 8aa523e..1830fa7 100644 --- a/internal/cli/ansible/ansible.go +++ b/internal/cli/ansible/ansible.go @@ -1,14 +1,18 @@ package ansible import ( + "archive/tar" "bytes" + "compress/gzip" "encoding/json" "errors" "fmt" "io" + "net/http" "os" "os/exec" "path/filepath" + "runtime" "strings" "text/template" @@ -33,13 +37,11 @@ func (e *PlaybookError) Unwrap() error { const inventoryTemplate = ` [appa] -{{.Host}} -ansible_user={{.User}} -ansible_port={{.Port}} -ansible_ssh_common_args='{{.SSHCommonArgs}}' +{{.Host}} ansible_user={{.User}} ansible_port={{.Port}} ansible_ssh_common_args='{{.SSHCommonArgs}}'{{if .IdentityFile}} ansible_ssh_private_key_file={{.IdentityFile}}{{end}} [appa:vars] ansible_python_interpreter=/usr/bin/python3 +ansible_remote_tmp=/tmp/.ansible ` // inventoryData holds the template variables for @@ -48,6 +50,7 @@ type inventoryData struct { Host string User string Port int + IdentityFile string SSHCommonArgs string } @@ -59,14 +62,15 @@ type Playbook struct { Tags string SkipTags string ExtraVars map[string]any + Quiet bool } const UserDeploy = "deploy" // GenerateInventory creates an Ansible inventory file on -// disk using the provided instance configuration. It sets +// disk using the provided server configuration. It sets // up the [appa] group with the host's SSH connection details. -func GenerateInventory(p config.InstanceConfig, dest string, skipVerify bool) error { +func GenerateInventory(p config.ServerConfig, dest string, skipVerify bool) error { if err := os.MkdirAll(filepath.Dir(dest), 0700); err != nil { return fmt.Errorf("create inventory dir: %w", err) } @@ -89,6 +93,7 @@ func GenerateInventory(p config.InstanceConfig, dest string, skipVerify bool) er Host: p.SSHHost, User: p.SSHUser, Port: p.SSHPort, + IdentityFile: p.SSHIdentityFile, SSHCommonArgs: sshArgs, }) } @@ -100,10 +105,135 @@ func ansibleDir() string { return ansibleExtractedDir() } +var errAnsibleMissing = fmt.Errorf("ansible-playbook not found on PATH; see the User Guide at https://github.com/theolujay/appa#readme") + +// runCmd is a convenience wrapper around exec.Command that +// streams stdout/stderr to the terminal. +func runCmd(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// ensureVenv returns the bin directory containing Ansible binaries. +// It checks, in order: +// 1. ansible-playbook already on $PATH +// 2. managed uv venv at ~/.appa/ansible/.venv/ +// 3. creates the venv (downloading uv if needed) +func ensureVenv() (string, error) { + if path, err := exec.LookPath("ansible-playbook"); err == nil { + return filepath.Dir(path), nil + } + + venvDir := filepath.Join(ansibleExtractedDir(), ".venv") + venvBinDir := filepath.Join(venvDir, "bin") + if _, err := os.Stat(filepath.Join(venvBinDir, "ansible-playbook")); err == nil { + return venvBinDir, nil + } + + uvPath, err := exec.LookPath("uv") + if err != nil { + uvPath, err = installUV() + if err != nil { + return "", errAnsibleMissing + } + } + + if err := os.MkdirAll(ansibleExtractedDir(), 0755); err != nil { + return "", fmt.Errorf("create ansible dir: %w", err) + } + + fmt.Print(" Creating Python venv with Ansible...\n") + if err := runCmd(uvPath, "venv", venvDir); err != nil { + return "", fmt.Errorf("create uv venv: %w", err) + } + installCmd := exec.Command(uvPath, "pip", "install", "ansible==14.1.0") + installCmd.Env = append(os.Environ(), "VIRTUAL_ENV="+venvDir) + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + if err := installCmd.Run(); err != nil { + return "", fmt.Errorf("install ansible: %w", err) + } + + return venvBinDir, nil +} + +// installUV downloads the uv binary to ~/.appa/bin/uv/ and returns its path. +func installUV() (string, error) { + var triple string + switch { + case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": + triple = "x86_64-unknown-linux-gnu" + case runtime.GOOS == "linux" && runtime.GOARCH == "arm64": + triple = "aarch64-unknown-linux-gnu" + case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64": + triple = "x86_64-apple-darwin" + case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": + triple = "aarch64-apple-darwin" + default: + return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) + } + + uvDir := filepath.Join(appaConfigDir(), "bin") + uvBin := filepath.Join(uvDir, "uv") + + if _, err := os.Stat(uvBin); err == nil { + return uvBin, nil + } + + url := fmt.Sprintf("https://github.com/astral-sh/uv/releases/latest/download/uv-%s.tar.gz", triple) + + fmt.Print(" Downloading uv...\n") + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("download uv: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download uv: HTTP %d", resp.StatusCode) + } + + gzr, err := gzip.NewReader(resp.Body) + if err != nil { + return "", fmt.Errorf("decompress uv: %w", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + for { + hdr, err := tr.Next() + if err == io.EOF { + return "", fmt.Errorf("uv binary not found in archive") + } + if err != nil { + return "", fmt.Errorf("read uv archive: %w", err) + } + if filepath.Base(hdr.Name) == "uv" { + if err := os.MkdirAll(uvDir, 0755); err != nil { + return "", fmt.Errorf("create uv dir: %w", err) + } + f, err := os.OpenFile(uvBin, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return "", fmt.Errorf("write uv: %w", err) + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return "", fmt.Errorf("extract uv: %w", err) + } + f.Close() + return uvBin, nil + } + } +} + // ensureDeps extracts embedded Ansible files to // disk and installs external Ansible Galaxy roles -// from requirements.yml. -func ensureDeps() error { +// from requirements.yml. It expects binDir to contain +// the ansible-galaxy binary. When quiet is true, +// progress output is suppressed (errors still surface). +func ensureDeps(binDir string, quiet bool) error { if err := ensureExtracted(); err != nil { return fmt.Errorf("extract ansible files: %w", err) } @@ -112,12 +242,23 @@ func ensureDeps() error { return nil } cmd := exec.Command( - "ansible-galaxy", "role", "install", "-r", "requirements.yml", + filepath.Join(binDir, "ansible-galaxy"), + "role", "install", "-r", "requirements.yml", ) cmd.Dir = ansibleDir() - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + if quiet { + cmd.Stdout = io.Discard + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + if err := cmd.Run(); err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderrBuf.String())) + } + } else { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + return nil } // RunPlaybook executes an Ansible playbook using @@ -126,7 +267,11 @@ func ensureDeps() error { // control the execution flow. Output is streamed // directly to standard output and error. func RunPlaybook(p Playbook) error { - if err := ensureDeps(); err != nil { + binDir, err := ensureVenv() + if err != nil { + return err + } + if err := ensureDeps(binDir, p.Quiet); err != nil { return fmt.Errorf("install galaxy deps: %w", err) } args := []string{ @@ -143,17 +288,28 @@ func RunPlaybook(p Playbook) error { if p.ExtraVars != nil { args = append(args, "-e", toJSONString(p.ExtraVars)) } - cmd := exec.Command("ansible-playbook", args...) + cmd := exec.Command(filepath.Join(binDir, "ansible-playbook"), args...) cmd.Dir = ansibleDir() cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - var errBuf bytes.Buffer - cmd.Stderr = io.MultiWriter(os.Stderr, &errBuf) + var stdoutBuf, stderrBuf bytes.Buffer + if p.Quiet { + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + } else { + cmd.Stdout = os.Stdout + cmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) + } if err := cmd.Run(); err != nil { + var detail string + if p.Quiet { + detail = strings.TrimSpace(stderrBuf.String() + "\n" + stdoutBuf.String()) + } else { + detail = strings.TrimSpace(stderrBuf.String()) + } return &PlaybookError{ Playbook: p.Name, - Err: fmt.Errorf("%w: %s", err, strings.TrimSpace(errBuf.String())), + Err: fmt.Errorf("%w: %s", err, detail), } } return nil diff --git a/internal/cli/ansible/embed.go b/internal/cli/ansible/embed.go index 4fcf345..334a700 100644 --- a/internal/cli/ansible/embed.go +++ b/internal/cli/ansible/embed.go @@ -28,13 +28,10 @@ func ansibleExtractedDir() string { } // ensureExtracted extracts embedded Ansible -// assetsto the local filesystem if missing. +// assets to the local filesystem, overwriting +// any existing files. func ensureExtracted() error { dir := ansibleExtractedDir() - marker := filepath.Join(dir, "playbooks", "deploy-stack.yml") - if _, err := os.Stat(marker); err == nil { - return nil - } return fs.WalkDir(ansibleFS, "_embed", func(path string, d fs.DirEntry, err error) error { if err != nil { return err diff --git a/internal/cli/app.go b/internal/cli/app.go index cfcb66f..b36ec77 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -11,28 +11,20 @@ func NewApp() *cobra.Command { Use: "appa", Short: "Appa CLI -- manage your Appa deployment platform", Long: ` - Appa CLI is the control surface for provisioning and operating self-hosted - Appa Server instances. It handles host-level configuration, pre-deployment - checks, security hardening, and remote lifecycle management of your server. +Appa CLI is the control surface for provisioning and operating self-hosted +Appa servers. It handles host-level configuration, pre-deployment +checks, security hardening, and remote lifecycle management of your server. - Typical setup workflow: - 1. Create a new instance: appa instance init - 2. Configure connection target: appa instance set-host root@ - 3. Validate remote environment: appa preflight - 4. Hardened stack deployment: appa setup +Typical setup workflow: +1. Create a new server: appa server init +2. Configure connection target: appa server set-host root@ +3. Validate remote environment: appa server preflight +4. Hardened stack deployment: appa server setup `, Version: vcs.Version(), } - rootCmd.AddCommand(commands.InstanceCmd()) - rootCmd.AddCommand(commands.PreflightCmd()) - rootCmd.AddCommand(commands.SetupCmd()) - rootCmd.AddCommand(commands.ApplyCmd()) - rootCmd.AddCommand(commands.StatusCmd()) - rootCmd.AddCommand(commands.LogsCmd()) - rootCmd.AddCommand(commands.RestartCmd()) - rootCmd.AddCommand(commands.UpgradeCmd()) - + rootCmd.AddCommand(commands.ServerCmd()) rootCmd.AddCommand(commands.ProjectCmd()) rootCmd.AddCommand(commands.DeployCmd()) diff --git a/internal/cli/commands/deploy.go b/internal/cli/commands/deploy.go index 04c7f82..3ffcbd9 100644 --- a/internal/cli/commands/deploy.go +++ b/internal/cli/commands/deploy.go @@ -12,23 +12,98 @@ import ( "github.com/theolujay/appa/internal/cli/config" "github.com/theolujay/appa/internal/cli/output" "github.com/theolujay/appa/internal/cli/ssh" + "github.com/theolujay/appa/internal/cli/tui" ) +// apiClient is a convenience wrapper for making authenticated API calls +// to the Appa server. It embeds the base URL and provides helper methods. +type apiClient struct { + baseURL string + client *http.Client +} + +func newAPIClient(baseURL string) *apiClient { + return &apiClient{ + baseURL: baseURL, + client: &http.Client{}, + } +} + +func (c *apiClient) ensureProject(name string) (*int64, error) { + body := struct { + Name string `json:"name"` + }{Name: name} + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal project request: %w", err) + } + + resp, err := c.client.Post(c.baseURL+"/v1/projects", "application/json", bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("create project api call failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusCreated { + var result struct { + Project struct { + ID int64 `json:"id"` + } `json:"project"` + } + if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode project response: %w", err) + } + return &result.Project.ID, nil + } + + // If duplicate, look up the existing project + if resp.StatusCode == http.StatusUnprocessableEntity { + resp2, err := c.client.Get(c.baseURL + "/v1/projects?name=" + name) + if err != nil { + return nil, fmt.Errorf("lookup project api call failed: %w", err) + } + defer resp2.Body.Close() + + if resp2.StatusCode != http.StatusOK { + return nil, fmt.Errorf("lookup project returned status %d", resp2.StatusCode) + } + + var result struct { + Projects []struct { + ID int64 `json:"id"` + } `json:"projects"` + } + if err = json.NewDecoder(resp2.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode project lookup response: %w", err) + } + if len(result.Projects) == 0 { + return nil, fmt.Errorf("project %q not found after creation attempt", name) + } + return &result.Projects[0].ID, nil + } + + return nil, fmt.Errorf("create project returned status %d", resp.StatusCode) +} + func DeployCmd() *cobra.Command { - var quiet bool + var ( + quiet bool + verbose bool + ) cmd := &cobra.Command{ Use: "deploy ", Short: "Deploy an already initialized project", Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { - return deployFunc(args, quiet) + return deployFunc(args, quiet, verbose) }, } cmd.Flags().BoolVar(&quiet, "quiet", false, "Suppress rsync progress output") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed output") return cmd } -func deployFunc(args []string, quiet bool) error { +func deployFunc(args []string, quiet, verbose bool) error { name := args[0] if !config.ProjectExists(name) { return fmt.Errorf("project %q doesn't exist: use 'appa project init '", name) @@ -42,17 +117,17 @@ func deployFunc(args []string, quiet bool) error { return fmt.Errorf("target not set: use 'appa project edit %s'", name) } - iCfg, err := config.LoadInstance(pCfg.Target) + iCfg, err := config.LoadServer(pCfg.Target) if err != nil { - return fmt.Errorf("load instance %q: %w", pCfg.Target, err) + return fmt.Errorf("load server %q: %w", pCfg.Target, err) } if !iCfg.SetupDone { - return fmt.Errorf("instance %q has not been set up: run 'appa setup %s' first", pCfg.Target, pCfg.Target) + return fmt.Errorf("server %q has not been set up: run 'appa setup %s' first", pCfg.Target, pCfg.Target) } - if iCfg.BaseAPIURL == "" { - return fmt.Errorf("instance %q has no API URL: run 'appa setup %s'", pCfg.Target, pCfg.Target) + if iCfg.APIBaseURL == "" { + return fmt.Errorf("server %q has no API URL: run 'appa setup %s'", pCfg.Target, pCfg.Target) } info, err := os.Stat(pCfg.Source) @@ -63,7 +138,7 @@ func deployFunc(args []string, quiet bool) error { return fmt.Errorf("project source %q is not a directory", pCfg.Source) } - serverDir := config.ServerDirFor(name) + serverRemoteDir := config.ServerRemoteDirFor(name) src, err := config.ParseProjectSource(pCfg.Source) if err != nil { return fmt.Errorf("unable to parse path %q: %w", pCfg.Source, err) @@ -78,29 +153,48 @@ func deployFunc(args []string, quiet bool) error { SkipVerify: skipVerify, } - output.Section("Shipping %s to %s", pCfg.Source, ssh.Target(iCfg.SSHUser, iCfg.SSHHost, iCfg.SSHPort)) + label := fmt.Sprintf("Shipping %s to %s", pCfg.Source, ssh.Target(iCfg.SSHUser, iCfg.SSHHost, iCfg.SSHPort)) var rOut, rErr io.Writer = os.Stdout, os.Stderr - if quiet { + if quiet || !verbose { rOut = io.Discard rErr = io.Discard } - if err := ssh.Rsync(client, src, serverDir, rOut, rErr); err != nil { + if verbose { + output.Section("%s", label) + err = ssh.Rsync(client, src, serverRemoteDir, rOut, rErr) + } else { + s := tui.StartSpinner(label) + err = ssh.Rsync(client, src, serverRemoteDir, rOut, rErr) + s.Stop(err == nil) + } + if err != nil { return fmt.Errorf("rsync failed: %w", err) } - output.Success("%s shipped to %s", pCfg.Source, serverDir) + output.Success("%s shipped to %s", pCfg.Source, serverRemoteDir) + + output.Section("Ensuring project exists on server") + api := newAPIClient(iCfg.APIBaseURL) + projectID, err := api.ensureProject(name) + if err != nil { + return fmt.Errorf("ensure project: %w", err) + } output.Section("Triggering deployment") body := struct { - Source string `json:"source"` + Source string `json:"source"` + ProjectName string `json:"project_name"` + ProjectID *int64 `json:"project_id"` }{ - Source: serverDir, + Source: serverRemoteDir, + ProjectName: name, + ProjectID: projectID, } payload, err := json.Marshal(body) if err != nil { return fmt.Errorf("marshal request: %w", err) } - resp, err := http.Post(iCfg.BaseAPIURL+"/v1/deployments", "application/json", bytes.NewReader(payload)) + resp, err := http.Post(iCfg.APIBaseURL+"/v1/deployments", "application/json", bytes.NewReader(payload)) if err != nil { return fmt.Errorf("api call failed: %w", err) } @@ -130,6 +224,6 @@ func deployFunc(args []string, quiet bool) error { result.Deployment.CreatedAt, }, } - output.PrintTable(h, r) + output.PrintTable(h, r, nil) return nil } diff --git a/internal/cli/commands/errors.go b/internal/cli/commands/errors.go index eeab7c3..21d6c92 100644 --- a/internal/cli/commands/errors.go +++ b/internal/cli/commands/errors.go @@ -5,8 +5,8 @@ import "errors" var ( // errInvalidPath = errors.New("invalid path") // errInvalidName = errors.New("invalid name") - errConfigNotFound = errors.New("config not found") + errConfigNotFound = errors.New("config not found") // errDuplicateConfig = errors.New("config already exists") - errNoSSHTarget = errors.New("no SSH target set") - errInvalidTarget = errors.New("target must be in format user@host or user@host:port") + errNoSSHTarget = errors.New("no SSH target set") + errInvalidTarget = errors.New("target must be in format user@host or user@host:port") ) diff --git a/internal/cli/commands/instance.go b/internal/cli/commands/instance.go deleted file mode 100644 index 9001170..0000000 --- a/internal/cli/commands/instance.go +++ /dev/null @@ -1,219 +0,0 @@ -package commands - -import ( - "fmt" - "os/user" - "strings" - - "github.com/spf13/cobra" - "github.com/theolujay/appa/internal/cli/config" - "github.com/theolujay/appa/internal/cli/output" - "github.com/theolujay/appa/internal/cli/ssh" -) - -// InstanceCmd returns the root command for managing Appa instances. -// It provides subcommands for initializing, editing, setting hosts, and listing configs. -func InstanceCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "instance", - Short: "Manage Appa instances", - } - - cmd.AddCommand(instanceInitCmd()) - cmd.AddCommand(instanceEditCmd()) - cmd.AddCommand(instanceSetHostCmd()) - cmd.AddCommand(instanceListCmd()) - - return cmd -} - -func instanceInitCmd() *cobra.Command { - var opName string - - cmd := &cobra.Command{ - Use: "init ", - Short: "Create a new instance", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return instanceInitFunc(args, opName) - }, - } - - cmd.Flags().StringVarP(&opName, "op-name", "", "", "Target instance username to set (default -> '$(whoami)`)") - return cmd -} - -func instanceEditCmd() *cobra.Command { - return &cobra.Command{ - Use: "edit ", - Short: "Edit instance config in $EDITOR", - Long: `Opens the instance config in the system editor for direct TOML editing. - -The editor is chosen from $APPA_EDITOR, $EDITOR, or defaults to "vi". -After saving, the file is validated. If invalid, you can re-edit or abort.`, - Args: cobra.ExactArgs(1), - RunE: instanceEditFunc, - } -} - -func instanceSetHostCmd() *cobra.Command { - var identityFile string - var skipVerify bool - cmd := &cobra.Command{ - Use: "set-host ", - Short: "Set SSH target for an instance config", - Args: cobra.ExactArgs(2), - RunE: func(_ *cobra.Command, args []string) error { - return instanceSetHostFunc(args, identityFile, skipVerify) - }, - } - cmd.Flags().StringVarP( - &identityFile, "identity-file", "i", "", "Path to SSH private key", - ) - cmd.Flags().BoolVar( - &skipVerify, "skip-verify", false, "Skip SSH host key verification", - ) - return cmd -} - -func instanceListCmd() *cobra.Command { - return &cobra.Command{ - Use: "list", - Short: "List all instances", - Args: cobra.NoArgs, - RunE: instanceListFunc, - } -} - -// instanceInitFunc handles the creation of a new instance with validation. -func instanceInitFunc(args []string, opName string) error { - name := args[0] - if config.InstanceExists(name) { - return fmt.Errorf("instance %q already exists", name) - } - - cfg := config.DefaultInstance(name) - if opName == "" { - u, err := user.Current() - if err != nil { - return fmt.Errorf("unable to derive current username: %w", err) - } - opName = u.Username - - } - cfg.OperatorUser = opName - - if err := config.SaveInstance(cfg); err != nil { - return fmt.Errorf("save config: %w", err) - } - output.Success("Instance %q created", name) - output.Success("\tNext: appa instance set-host %s user@host", name) - return nil -} - -// instanceEditFunc handles opening an instance config in the system editor for editing. -// It supports validation and re-editing on invalid configuration. -func instanceEditFunc(_ *cobra.Command, args []string) error { - name := args[0] - if !config.InstanceExists(name) { - return fmt.Errorf("%w: %s", errConfigNotFound, name) - } - - return config.Edit(config.Instance, name) -} - -// instanceSetHostFunc sets the SSH target for an instance and tests the connection. -func instanceSetHostFunc(args []string, identityFile string, skipVerify bool) error { - name, target := args[0], args[1] - if !config.InstanceExists(name) { - return fmt.Errorf("%w: %s", errConfigNotFound, name) - } - user, host, port, err := parseTarget(target) - if err != nil { - return fmt.Errorf("invalid target: %w", err) - } - fmt.Printf("Testing SSH connection to %s...\n", ssh.Target(user, host, port)) - client := ssh.Client{ - User: user, - Host: host, - Port: port, - IdentityFile: identityFile, - SkipVerify: skipVerify, - } - if err := client.TestConnect(); err != nil { - return fmt.Errorf("SSH connection test failed: %w", err) - } - cfg, err := config.LoadInstance(name) - if err != nil { - return err - } - cfg.SSHUser = user - cfg.SSHHost = host - cfg.SSHPort = port - cfg.SSHIdentityFile = identityFile - cfg.SSHSkipVerify = skipVerify - if err := config.SaveInstance(cfg); err != nil { - return fmt.Errorf("save instance: %w", err) - } - // Since testing SSH connetion was successful, ignore error returned, as - // anything could cause a temporary issue and this is only best-effort. - ip, _ := ssh.ResolveIP(host) - if ip != "" { - output.Success("Resolved %s -> %s", host, ip) - } - output.Success( - "SSH target set for %q: %s", name, ssh.Target(user, host, port), - ) - return nil -} - -// instanceListFunc displays all instances and their current status. -func instanceListFunc(_ *cobra.Command, _ []string) error { - cfgs, err := config.ListInstances() - if err != nil { - return err - } - if len(cfgs) == 0 { - fmt.Println("No instance found.") - fmt.Println(" Create one: appa instance init ") - return nil - } - var rows [][]string - for _, p := range cfgs { - host := p.SSHHost - if host == "" { - host = "-" - } - status := "pending" - if p.SetupDone { - status = "done" - } else if p.SSHHost != "" { - status = "host set" - } - rows = append(rows, []string{p.Name, host, status}) - } - output.PrintTable([]string{"Name", "Host", "Status"}, rows) - return nil -} - -// parseTarget splits an SSH connection string into its components. -// It supports formats like "user@host" and "user@host:port", defaulting to port 22. -func parseTarget(target string) (user, host string, port int, err error) { - port = 22 - at := strings.LastIndex(target, "@") - if at < 1 { - return "", "", 0, errInvalidTarget - } - user = target[:at] - rest := target[at+1:] - if colon := strings.LastIndex(rest, ":"); colon > 0 { - host = rest[:colon] - fmt.Sscanf(rest[colon+1:], "%d", &port) - } else { - host = rest - } - if user == "" || host == "" { - return "", "", 0, errInvalidTarget - } - return user, host, port, nil -} diff --git a/internal/cli/commands/operations.go b/internal/cli/commands/operations.go index 1261adb..b4e71f9 100644 --- a/internal/cli/commands/operations.go +++ b/internal/cli/commands/operations.go @@ -18,7 +18,7 @@ import ( func StatusCmd() *cobra.Command { return &cobra.Command{ Use: "status ", - Short: "Show instance health and service status", + Short: "Show server health and service status", Args: cobra.ExactArgs(1), RunE: statusFunc, } @@ -68,14 +68,14 @@ func UpgradeCmd() *cobra.Command { return cmd } -// statusFunc checks and displays the health status of an instance, including +// statusFunc checks and displays the health status of a server, including // SSH connectivity, API health, Docker Compose services, and disk usage. func statusFunc(_ *cobra.Command, args []string) error { name := args[0] - if !config.InstanceExists(name) { + if !config.ServerExists(name) { return fmt.Errorf("%w: %s", errConfigNotFound, name) } - p, err := config.LoadInstance(name) + p, err := config.LoadServer(name) if err != nil { return fmt.Errorf("load config %q: %w", name, err) } @@ -101,7 +101,7 @@ func statusFunc(_ *cobra.Command, args []string) error { case p.SetupDone && sshOK: fmt.Print(" Checking API health...\n") hClient := &http.Client{Timeout: 5 * time.Second} - resp, err := hClient.Get(p.BaseAPIURL + "/v1/healthcheck") + resp, err := hClient.Get(p.APIBaseURL + "/v1/healthcheck") if err != nil { return fmt.Errorf("unable to reach API: %w", err) } @@ -149,7 +149,7 @@ func statusFunc(_ *cobra.Command, args []string) error { case !sshOK: output.Warn("Cannot check further: SSH not reachable") default: - output.Warn("Instance not yet set up; run 'appa setup %s'", name) + output.Warn("Server not yet set up; run 'appa setup %s'", name) } return nil } @@ -158,10 +158,10 @@ func statusFunc(_ *cobra.Command, args []string) error { // with optional service filtering and line count limits. func logsFunc(args []string, service string, tail int) error { name := args[0] - if !config.InstanceExists(name) { + if !config.ServerExists(name) { return fmt.Errorf("%w: %s", errConfigNotFound, name) } - p, err := config.LoadInstance(name) + p, err := config.LoadServer(name) if err != nil { return fmt.Errorf("load config %q: %w", name, err) } @@ -193,10 +193,10 @@ func logsFunc(args []string, service string, tail int) error { // optionally limiting to a specific service. func restartFunc(args []string, service string) error { name := args[0] - if !config.InstanceExists(name) { + if !config.ServerExists(name) { return fmt.Errorf("%w: %s", errConfigNotFound, name) } - p, err := config.LoadInstance(name) + p, err := config.LoadServer(name) if err != nil { return fmt.Errorf("load config %q: %w", name, err) } @@ -229,10 +229,10 @@ func restartFunc(args []string, service string) error { // version tag. It waits for the API to become healthy after upgrade. func upgradeFunc(args []string, version string) error { name := args[0] - if !config.InstanceExists(name) { + if !config.ServerExists(name) { return fmt.Errorf("%w: %s", errConfigNotFound, name) } - p, err := config.LoadInstance(name) + p, err := config.LoadServer(name) if err != nil { return fmt.Errorf("load config %q: %w", name, err) } @@ -271,8 +271,8 @@ func upgradeFunc(args []string, version string) error { fmt.Print(out) output.Success("Waiting for API...") - apiURL := p.BaseAPIURL + "/v1/healthcheck" - if p.BaseAPIURL == "" { + apiURL := p.APIBaseURL + "/v1/healthcheck" + if p.APIBaseURL == "" { apiURL = fmt.Sprintf("http://%s:8080/v1/healthcheck", p.SSHHost) } if err := pollHealth(apiURL, 60*time.Second); err != nil { diff --git a/internal/cli/commands/preflight.go b/internal/cli/commands/preflight.go index 26a796a..d3c45f3 100644 --- a/internal/cli/commands/preflight.go +++ b/internal/cli/commands/preflight.go @@ -1,55 +1,56 @@ package commands import ( + "errors" "fmt" "net" "strings" "time" + tea "charm.land/bubbletea/v2" "github.com/spf13/cobra" "github.com/theolujay/appa/internal/cli/config" "github.com/theolujay/appa/internal/cli/output" "github.com/theolujay/appa/internal/cli/ssh" + "github.com/theolujay/appa/internal/cli/tui" ) func PreflightCmd() *cobra.Command { var skipVerify bool + var noTTY bool cmd := &cobra.Command{ Use: "preflight ", - Short: "Run preflight checks on a target instance", + Short: "Run preflight checks on a target server", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return preflightFunc(cmd, args, skipVerify) + return preflightFunc(cmd, args, skipVerify, noTTY) }, } cmd.Flags().BoolVar( &skipVerify, "skip-verify", false, "Skip SSH host key verification", ) + cmd.Flags().BoolVar( + &noTTY, "no-tty", false, "Run in non-interactive mode (no TUI)", + ) return cmd } -// preflightFunc performs comprehensive preflight checks on a target instance, +// preflightFunc performs comprehensive preflight checks on a target server, // validating SSH connectivity, OS compatibility, required ports, DNS resolution, // Docker installation status, and configuration requirements. -func preflightFunc(_ *cobra.Command, args []string, skipVerify bool) error { +func preflightFunc(_ *cobra.Command, args []string, skipVerify bool, noTTY bool) error { name := args[0] - if !config.InstanceExists(name) { + if !config.ServerExists(name) { return fmt.Errorf("%w: %s", errConfigNotFound, name) } - p, err := config.LoadInstance(name) + p, err := config.LoadServer(name) if err != nil { return err } if p.SSHHost == "" { - return fmt.Errorf("no SSH target set for %q; run 'appa instance set-host %s user@host': %w", name, name, errNoSSHTarget) + return fmt.Errorf("no SSH target set for %q; run 'appa server set-host %s user@host': %w", name, name, errNoSSHTarget) } - output.Section("Preflight Checks for %q", name) - failures := 0 - warnings := 0 - - output.Check("Instance exists", true) - clientConfig := ssh.Client{ User: p.SSHUser, Host: p.SSHHost, @@ -58,84 +59,138 @@ func preflightFunc(_ *cobra.Command, args []string, skipVerify bool) error { SkipVerify: skipVerify || p.SSHSkipVerify, } - fmt.Printf(" Checking SSH connectivity to %s...\n", ssh.Target(p.SSHUser, p.SSHHost, p.SSHPort)) - if err := clientConfig.TestConnect(); err != nil { - output.Check("SSH reachable", false) - failures++ - } else { - output.Check("SSH reachable", true) + checks := []tui.Check{ + { + Label: "Server exists", + Fn: func() (bool, string, bool) { + return true, "", false + }, + }, + { + Label: "SSH reachable", + Fn: func() (bool, string, bool) { + err := clientConfig.TestConnect() + if err != nil { + return false, err.Error(), false + } + return true, "", false + }, + }, + { + Label: "OS supported (Ubuntu)", + Fn: func() (bool, string, bool) { + out, err := ssh.RunCommand(clientConfig, "cat /etc/os-release 2>/dev/null | grep -i ^ID=") + if err != nil || !strings.Contains(strings.ToLower(out), "ubuntu") { + return false, "", false + } + return true, "", false + }, + }, + { + Label: fmt.Sprintf("Required ports reachable [%d, 80, 443]", p.SSHPort), + Fn: func() (bool, string, bool) { + var errs []error + ports := []int{p.SSHPort, 80, 443} + for _, port := range ports { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(p.SSHHost, fmt.Sprintf("%d", port)), 3*time.Second) + if err != nil { + errs = append(errs, fmt.Errorf("port %d not reachable from here", port)) + continue + } + conn.Close() + } + if len(errs) > 0 { + err := errors.Join(errs...) + return true, fmt.Sprintf("%v", err), true + } + return true, "", false + }, + }, + { + Label: "Domain resolves to server IP", + Fn: func() (bool, string, bool) { + if p.Domain == "" { + return true, "No domain configured", true + } + ip, _ := ssh.ResolveIP(p.SSHHost) + domainIP, err := net.LookupHost(p.Domain) + if err != nil { + return true, fmt.Sprintf("Domain %q does not resolve", p.Domain), true + } else if ip != "" && domainIP[0] != ip { + return true, fmt.Sprintf("Resolves to %s, not %s", domainIP[0], ip), true + } + return true, "", false + }, + }, + { + Label: "Docker not already installed (clean host)", + Fn: func() (bool, string, bool) { + out, _ := ssh.RunCommand(clientConfig, "which docker 2>/dev/null && docker --version 2>/dev/null || echo 'not found'") + if strings.Contains(out, "not found") { + return true, "", false + } + return true, strings.TrimSpace(out), true + }, + }, + { + Label: "Cloudflare API token set", + Fn: func() (bool, string, bool) { + if p.CloudflareToken == "" { + return true, "Token not set (needed for wildcard TLS)", true + } + return true, "", false + }, + }, + { + Label: "SMTP configured", + Fn: func() (bool, string, bool) { + if p.SMTPHost == "" { + return true, "SMTP not configured", true + } + return true, "", false + }, + }, } - fmt.Print(" Checking OS compatibility...\n") - out, err := ssh.RunCommand(clientConfig, "cat /etc/os-release 2>/dev/null | grep -i ^ID=") - if err != nil || !strings.Contains(strings.ToLower(out), "ubuntu") { - output.Check("OS supported (Ubuntu)", false) - failures++ - } else { - output.Check("OS supported (Ubuntu)", true) + if noTTY { + return runChecksPlain(checks) } - fmt.Print(" Checking required ports...\n") - ports := []int{22, 80, 443} - allOpen := true - for _, port := range ports { - conn, err := net.DialTimeout("tcp", net.JoinHostPort(p.SSHHost, fmt.Sprintf("%d", port)), 3*time.Second) - if err != nil { - allOpen = false - output.Warn(fmt.Sprintf("Port %d not reachable from here", port)) - warnings++ - } else { - conn.Close() - } - } - if allOpen { - output.Check("Required ports reachable (22, 80, 443)", true) + model := tui.NewPreflightModel(checks) + pProg := tea.NewProgram(model) + m, err := pProg.Run() + if err != nil { + return fmt.Errorf("error running preflight TUI: %w", err) } - ip, _ := ssh.ResolveIP(p.SSHHost) - if p.Domain != "" { - domainIP, err := net.LookupHost(p.Domain) - if err != nil { - output.Warn(fmt.Sprintf("Domain %q does not resolve", p.Domain)) - warnings++ - } else if ip != "" && domainIP[0] != ip { - output.Warn(fmt.Sprintf("Domain %q resolves to %s, not instance IP %s", p.Domain, domainIP[0], ip)) - warnings++ - } else { - output.Check(fmt.Sprintf("Domain %q resolves to instance IP", p.Domain), true) + if pm, ok := m.(*tui.PreflightModel); ok { + if pm.Failures > 0 { + return fmt.Errorf("preflight failed") } } - fmt.Print(" Checking for existing Docker...\n") - out, _ = ssh.RunCommand(clientConfig, "which docker 2>/dev/null && docker --version 2>/dev/null || echo 'not found'") - if strings.Contains(out, "not found") { - output.Check("Docker not installed (clean host)", true) - } else { - output.Warn(fmt.Sprintf("Docker already installed: %s", strings.TrimSpace(out))) - warnings++ - } + return nil +} - if p.CloudflareToken == "" { - output.Warn("Cloudflare API token not set (needed for wildcard TLS)") - warnings++ - } else { - output.Check("Cloudflare API token set", true) - } - if p.SMTPHost == "" { - output.Warn("SMTP not configured (needed for email notifications)") - warnings++ - } else { - output.Check("SMTP configured", true) +func runChecksPlain(checks []tui.Check) error { + failures := 0 + for _, c := range checks { + ok, info, warn := c.Fn() + switch { + case !ok: + output.Check(c.Label, false) + failures++ + case warn: + output.Warn("%s", c.Label) + default: + output.Check(c.Label, true) + } + if info != "" { + fmt.Printf(" %s\n", info) + } } - - fmt.Println() if failures > 0 { - output.Error("%d failure(s), %d warning(s)", failures, warnings) - return fmt.Errorf("preflight failed") - } else if warnings > 0 { - output.Success("All critical checks passed (%d warning(s))", warnings) - } else { - output.Success("All checks passed") + return fmt.Errorf("preflight failed with %d failure(s)", failures) } return nil } diff --git a/internal/cli/commands/project.go b/internal/cli/commands/project.go index fc66f1a..21dba15 100644 --- a/internal/cli/commands/project.go +++ b/internal/cli/commands/project.go @@ -1,12 +1,16 @@ package commands import ( + "encoding/json" "errors" "fmt" + "net/http" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" "github.com/theolujay/appa/internal/cli/config" "github.com/theolujay/appa/internal/cli/output" + "github.com/theolujay/appa/internal/cli/tui" ) func ProjectCmd() *cobra.Command { @@ -17,22 +21,228 @@ func ProjectCmd() *cobra.Command { cmd.AddCommand(projectInitCmd()) cmd.AddCommand(projectEditCmd()) + cmd.AddCommand(projectLogsCmd()) + cmd.AddCommand(projectStopCmd()) + cmd.AddCommand(projectRestartCmd()) return cmd } +func projectLogsCmd() *cobra.Command { + return &cobra.Command{ + Use: "logs [name]", + Short: "Stream deployment logs for a project", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var name string + if len(args) > 0 { + name = args[0] + } + if name == "" { + if err := promptProjectName(&name, "view logs"); err != nil { + return err + } + } + return projectLogsFunc(cmd, []string{name}) + }, + } +} + +func projectStopCmd() *cobra.Command { + return &cobra.Command{ + Use: "stop [name]", + Short: "Stop the latest deployment for a project", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var name string + if len(args) > 0 { + name = args[0] + } + if name == "" { + if err := promptProjectName(&name, "stop"); err != nil { + return err + } + } + return projectStopFunc(cmd, []string{name}) + }, + } +} + +func projectRestartCmd() *cobra.Command { + return &cobra.Command{ + Use: "restart [name]", + Short: "Restart the latest deployment for a project", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var name string + if len(args) > 0 { + name = args[0] + } + if name == "" { + if err := promptProjectName(&name, "restart"); err != nil { + return err + } + } + return projectRestartFunc(cmd, []string{name}) + }, + } +} + +func promptProjectName(name *string, action string) error { + cfgs, err := config.ListProjects() + if err != nil { + return err + } + if len(cfgs) == 0 { + return fmt.Errorf("no projects found, run 'appa project init' first") + } + options := make([]huh.Option[string], len(cfgs)) + for i, cfg := range cfgs { + options[i] = huh.NewOption(cfg.Name, cfg.Name) + } + return huh.NewSelect[string](). + Title(fmt.Sprintf("Select a project to %s:", action)). + Options(options...). + Value(name). + Run() +} + +func getLatestDeployment(name string) (int64, string, error) { + if !config.ProjectExists(name) { + return 0, "", fmt.Errorf("project %q doesn't exist", name) + } + + pCfg, err := config.LoadProject(name) + if err != nil { + return 0, "", fmt.Errorf("load project: %w", err) + } + if pCfg.Target == "" { + return 0, "", fmt.Errorf("target not set: use 'appa project edit %s'", name) + } + + iCfg, err := config.LoadServer(pCfg.Target) + if err != nil { + return 0, "", fmt.Errorf("load server %q: %w", pCfg.Target, err) + } + if !iCfg.SetupDone { + return 0, "", fmt.Errorf("server %q has not been set up", pCfg.Target) + } + if iCfg.APIBaseURL == "" { + return 0, "", fmt.Errorf("server %q has no API URL", pCfg.Target) + } + + apiURL := iCfg.APIBaseURL + url := fmt.Sprintf("%s/v1/deployments?project_name=%s&sort=-id&page_size=1", apiURL, name) + resp, err := http.Get(url) + if err != nil { + return 0, "", fmt.Errorf("api call failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, "", fmt.Errorf("api returned status %d", resp.StatusCode) + } + + var result struct { + Deployments []struct { + ID int64 `json:"id"` + } `json:"deployments"` + } + if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, "", fmt.Errorf("decode response: %w", err) + } + if len(result.Deployments) == 0 { + return 0, "", fmt.Errorf("no deployments found for project %q", name) + } + + return result.Deployments[0].ID, apiURL, nil +} + +func projectLogsFunc(_ *cobra.Command, args []string) error { + name := args[0] + deploymentID, apiURL, err := getLatestDeployment(name) + if err != nil { + return err + } + return tui.Run(tui.NewLogViewer(apiURL, deploymentID)) +} + +func projectStopFunc(_ *cobra.Command, args []string) error { + name := args[0] + deploymentID, apiURL, err := getLatestDeployment(name) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/v1/deployments/%d/stop", apiURL, deploymentID), nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("api call failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("api returned status %d", resp.StatusCode) + } + + output.Success("Project %q stopped (deployment %d)", name, deploymentID) + return nil +} + +func projectRestartFunc(_ *cobra.Command, args []string) error { + name := args[0] + deploymentID, apiURL, err := getLatestDeployment(name) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/v1/deployments/%d/restart", apiURL, deploymentID), nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("api call failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("api returned status %d", resp.StatusCode) + } + + output.Success("Project %q restarted (deployment %d)", name, deploymentID) + return nil +} + func projectInitCmd() *cobra.Command { var target string var name string cmd := &cobra.Command{ - Use: "init ", + Use: "init [source]", Short: "Create a new project", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { - return projectInitFunc(args, target, name) + var source string + if len(args) > 0 { + source = args[0] + } + if source == "" { + err := huh.NewInput(). + Title("What is the source directory?"). + Placeholder("e.g. . or ./my-app"). + Value(&source). + Run() + if err != nil { + return err + } + } + return projectInitFunc([]string{source}, target, name) }, } - cmd.Flags().StringVarP(&target, "target", "t", "", "Target instance name") + cmd.Flags().StringVarP(&target, "target", "t", "", "Target server name") cmd.Flags().StringVarP(&name, "name", "n", "", "Project name (inferred from source if not specified)") return cmd @@ -40,14 +250,25 @@ func projectInitCmd() *cobra.Command { func projectEditCmd() *cobra.Command { return &cobra.Command{ - Use: "edit ", + Use: "edit [name]", Short: "Edit project config in $EDITOR", Long: `Opens the project config in the system editor for direct TOML editing. The editor is chosen from $APPA_EDITOR, $EDITOR, or defaults to "vi". After saving, the file is validated. If invalid, you can re-edit or abort.`, - Args: cobra.ExactArgs(1), - RunE: projectEditFunc, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var name string + if len(args) > 0 { + name = args[0] + } + if name == "" { + if err := promptProjectName(&name, "edit"); err != nil { + return err + } + } + return projectEditFunc(cmd, []string{name}) + }, } } @@ -73,7 +294,7 @@ func projectInitFunc(args []string, target, name string) error { errs = append(errs, fmt.Errorf("project already exists: %s", name)) } - if target != "" && !config.InstanceExists(target) { + if target != "" && !config.ServerExists(target) { errs = append(errs, fmt.Errorf("target not found: %s", target)) } diff --git a/internal/cli/commands/server.go b/internal/cli/commands/server.go new file mode 100644 index 0000000..cbd1a85 --- /dev/null +++ b/internal/cli/commands/server.go @@ -0,0 +1,317 @@ +package commands + +import ( + "fmt" + "os/user" + "path/filepath" + "strings" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "github.com/theolujay/appa/internal/cli/config" + "github.com/theolujay/appa/internal/cli/output" + "github.com/theolujay/appa/internal/cli/ssh" +) + +func ServerCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "server", + Short: "Manage Appa servers", + } + + cmd.AddCommand(serverInitCmd()) + cmd.AddCommand(serverEditCmd()) + cmd.AddCommand(serverSetHostCmd()) + cmd.AddCommand(serverListCmd()) + cmd.AddCommand(PreflightCmd()) + cmd.AddCommand(SetupCmd()) + cmd.AddCommand(ApplyCmd()) + cmd.AddCommand(StatusCmd()) + cmd.AddCommand(LogsCmd()) + cmd.AddCommand(RestartCmd()) + cmd.AddCommand(UpgradeCmd()) + + return cmd +} + +func serverInitCmd() *cobra.Command { + var ( + opName string + host string + ) + + cmd := &cobra.Command{ + Use: "init [name]", + Short: "Create a new server", + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + var name string + if len(args) > 0 { + name = args[0] + } + if name == "" { + err := huh.NewInput(). + Title("What do you want to name this server?"). + Placeholder("e.g. personal"). + Value(&name). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("name cannot be empty") + } + if config.ServerExists(s) { + return fmt.Errorf("server %q already exists", s) + } + return nil + }). + Run() + if err != nil { + return err + } + } + return serverInitFunc([]string{name}, host, opName) + }, + } + + cmd.Flags().StringVar(&host, "host", "", "SSH Target server (e.g. user@203.0.113.10)") + cmd.Flags().StringVarP(&opName, "op-name", "", "", "Target server user name to set (default -> '$(whoami)')") + + return cmd +} + +func serverEditCmd() *cobra.Command { + return &cobra.Command{ + Use: "edit ", + Short: "Edit server config in $EDITOR", + Long: `Opens the server config in the system editor for direct TOML editing. + +The editor is chosen from $APPA_EDITOR, $EDITOR, or defaults to "vi". +After saving, the file is validated. If invalid, you can re-edit or abort.`, + Args: cobra.ExactArgs(1), + RunE: serverEditFunc, + } +} + +func serverSetHostCmd() *cobra.Command { + var ( + identityFile string + skipVerify bool + apiPort int + ) + + cmd := &cobra.Command{ + Use: "set-host [name] [target]", + Short: "Set SSH target for a server config", + Args: cobra.MaximumNArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + var name, target string + if len(args) > 0 { + name = args[0] + } + if len(args) > 1 { + target = args[1] + } + + if name == "" || target == "" { + var fields []huh.Field + + if name == "" { + cfgs, err := config.ListServers() + if err != nil { + return err + } + if len(cfgs) == 0 { + return fmt.Errorf("no servers found, run 'appa server init' first") + } + options := []huh.Option[string]{} + for _, cfg := range cfgs { + options = append(options, huh.NewOption(cfg.Name, cfg.Name)) + } + fields = append(fields, huh.NewSelect[string](). + Title("Select a server:"). + Options(options...). + Value(&name)) + } + + if target == "" { + fields = append(fields, huh.NewInput(). + Title("What is the SSH target?"). + Placeholder("root@203.0.113.10"). + Value(&target). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("target cannot be empty") + } + if _, _, _, err := parseTarget(s); err != nil { + return fmt.Errorf("invalid format, use user@host or user@host:port") + } + return nil + })) + } + + err := huh.NewForm(huh.NewGroup(fields...)).Run() + if err != nil { + return err + } + } + + return serverSetHostFunc([]string{name, target}, identityFile, skipVerify, apiPort) + }, + } + cmd.Flags().IntVar(&apiPort, "port", 0, "API port (e.g. 8080 for Vagrant forwarded port; default 80 for http)") + cmd.Flags().StringVarP( + &identityFile, "identity-file", "i", "", "Path to SSH private key", + ) + cmd.Flags().BoolVar( + &skipVerify, "skip-verify", false, "Skip SSH host key verification", + ) + return cmd +} + +func serverListCmd() *cobra.Command { + return &cobra.Command{ + Use: "ls", + Short: "List all servers", + Args: cobra.NoArgs, + RunE: serverListFunc, + } +} + +func serverInitFunc(args []string, host, opName string) error { + name := args[0] + if config.ServerExists(name) { + return fmt.Errorf("server %q already exists", name) + } + + cfg := config.DefaultServer(name) + if opName == "" { + u, err := user.Current() + if err != nil { + return fmt.Errorf("unable to derive current username: %w", err) + } + opName = u.Username + + } + cfg.SSHHost = host + cfg.OperatorUser = opName + + if err := config.SaveServer(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + output.Success("Server %q created", name) + if host == "" { + output.Success("\tNext: appa server set-host %s user@host", name) + } + return nil +} + +func serverEditFunc(_ *cobra.Command, args []string) error { + name := args[0] + if !config.ServerExists(name) { + return fmt.Errorf("%w: %s", errConfigNotFound, name) + } + + return config.Edit(config.Server, name) +} + +func serverSetHostFunc(args []string, identityFile string, skipVerify bool, apiPort int) error { + name, target := args[0], args[1] + if !config.ServerExists(name) { + return fmt.Errorf("%w: %s", errConfigNotFound, name) + } + user, host, port, err := parseTarget(target) + if err != nil { + return fmt.Errorf("invalid target: %w", err) + } + fmt.Printf("Testing SSH connection to %s...\n", ssh.Target(user, host, port)) + client := ssh.Client{ + User: user, + Host: host, + Port: port, + IdentityFile: identityFile, + SkipVerify: skipVerify, + } + if err := client.TestConnect(); err != nil { + return fmt.Errorf("SSH connection test failed: %w", err) + } + cfg, err := config.LoadServer(name) + if err != nil { + return err + } + cfg.SSHUser = user + cfg.SSHHost = host + cfg.SSHPort = port + if apiPort > 0 { + cfg.APIPort = apiPort + } + if identityFile != "" { + abs, err := filepath.Abs(identityFile) + if err == nil { + identityFile = abs + } + } + cfg.SSHIdentityFile = identityFile + cfg.SSHSkipVerify = skipVerify + if err := config.SaveServer(cfg); err != nil { + return fmt.Errorf("save server: %w", err) + } + // Since testing SSH connection was successful, ignore error returned, as + // anything could cause a temporary issue and this is only best-effort. + ip, _ := ssh.ResolveIP(host) + if ip != "" { + output.Success("Resolved %s -> %s", host, ip) + } + output.Success( + "SSH target set for %q: %s", name, ssh.Target(user, host, port), + ) + return nil +} + +func serverListFunc(_ *cobra.Command, _ []string) error { + cfgs, err := config.ListServers() + if err != nil { + return err + } + if len(cfgs) == 0 { + output.Warn("No server found.") + output.Success("Create one: appa server init ") + return nil + } + var rows [][]string + var dimmed []bool + for _, p := range cfgs { + host := p.SSHHost + status := "pending" + dim := true + if host == "" { + host = "-" + } else if p.SetupDone { + status = "done" + dim = false + } + + rows = append(rows, []string{p.Name, host, status}) + dimmed = append(dimmed, dim) + } + output.PrintTable([]string{"Name", "Host", "Status"}, rows, dimmed) + return nil +} + +func parseTarget(target string) (user, host string, port int, err error) { + port = 22 + at := strings.LastIndex(target, "@") + if at < 1 { + return "", "", 0, errInvalidTarget + } + user = target[:at] + rest := target[at+1:] + if colon := strings.LastIndex(rest, ":"); colon > 0 { + host = rest[:colon] + fmt.Sscanf(rest[colon+1:], "%d", &port) + } else { + host = rest + } + if user == "" || host == "" { + return "", "", 0, errInvalidTarget + } + return user, host, port, nil +} diff --git a/internal/cli/commands/setup.go b/internal/cli/commands/setup.go index 592d3f3..a439952 100644 --- a/internal/cli/commands/setup.go +++ b/internal/cli/commands/setup.go @@ -12,10 +12,11 @@ import ( "github.com/theolujay/appa/internal/cli/config" "github.com/theolujay/appa/internal/cli/output" "github.com/theolujay/appa/internal/cli/ssh" + "github.com/theolujay/appa/internal/cli/tui" "github.com/theolujay/appa/internal/vcs" ) -// SetupCmd returns a command that performs the first-time provisioning of an Appa instance. +// SetupCmd returns a command that performs the first-time provisioning of an Appa server. // It runs preflight checks, generates an Ansible inventory, and executes playbooks for // security hardening and stack deployment. // @@ -32,27 +33,29 @@ func SetupCmd() *cobra.Command { skipTags string skipVerify bool opPubKey string + verbose bool ) cmd := &cobra.Command{ Use: "setup ", - Short: "First-time provisioning of an Appa instance", + Short: "First-time provisioning of an Appa server", Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { - return setupFunc(args, opPubKey, force, tags, skipTags, skipVerify) + return setupFunc(args, opPubKey, tags, skipTags, skipVerify, force, verbose) }, } - cmd.Flags().StringVarP(&opPubKey, "op-key", "", "", "SSH public key for instance username") + cmd.Flags().StringVarP(&opPubKey, "op-key", "", "", "SSH public key for server username") cmd.Flags().BoolVar(&force, "force", false, "Skip preflight checks") cmd.Flags().StringVar(&tags, "tags", "", "Only run Ansible tasks with these tags") cmd.Flags().StringVar(&skipTags, "skip-tags", "", "Skip Ansible tasks with these tags") cmd.Flags().BoolVar(&skipVerify, "skip-verify", false, "Skip SSH host key verification") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed Ansible output") return cmd } -// ApplyCmd returns a command that re-applies configuration changes to an existing instance. +// ApplyCmd returns a command that re-applies configuration changes to an existing server. // It is intended to be idempotent and used for updating settings like domains or firewall rules. // // Example output: @@ -66,6 +69,7 @@ func ApplyCmd() *cobra.Command { tags string skipTags string skipVerify bool + verbose bool ) cmd := &cobra.Command{ @@ -73,24 +77,25 @@ func ApplyCmd() *cobra.Command { Short: "Re-apply configuration changes idempotently", Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { - return applyFunc(args, tags, skipTags, skipVerify) + return applyFunc(args, tags, skipTags, skipVerify, verbose) }, } cmd.Flags().StringVar(&tags, "tags", "", "Only run Ansible tasks with these tags") cmd.Flags().StringVar(&skipTags, "skip-tags", "", "Skip Ansible tasks with these tags") cmd.Flags().BoolVar(&skipVerify, "skip-verify", false, "Skip SSH host key verification") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed Ansible output") return cmd } // setupFunc handles the logic for the setup command, coordinating preflight checks, // inventory generation, and playbook execution. -func setupFunc(args []string, opPubKey string, force bool, tags, skipTags string, skipVerify bool) error { +func setupFunc(args []string, opPubKey, tags, skipTags string, skipVerify, force, verbose bool) error { name := args[0] - if !config.InstanceExists(name) { + if !config.ServerExists(name) { return fmt.Errorf("%w: %s", errConfigNotFound, name) } - cfg, err := config.LoadInstance(name) + cfg, err := config.LoadServer(name) if err != nil { return err } @@ -98,7 +103,7 @@ func setupFunc(args []string, opPubKey string, force bool, tags, skipTags string return fmt.Errorf("%w: %s", errNoSSHTarget, name) } if cfg.SetupDone { - output.Warn("Instance %q has already been set up; use 'appa apply %s' for changes", name, name) + output.Warn("Server %q has already been set up; use 'appa apply %s' for changes", name, name) } skipVerify = skipVerify || cfg.SSHSkipVerify @@ -106,7 +111,11 @@ func setupFunc(args []string, opPubKey string, force bool, tags, skipTags string if !force { fmt.Println("Running preflight checks...") preflightCmd := PreflightCmd() - preflightCmd.SetArgs([]string{name, "--skip-verify=" + fmt.Sprintf("%t", skipVerify)}) + preflightCmd.SetArgs([]string{ + name, + "--skip-verify=" + fmt.Sprintf("%t", skipVerify), + "--no-tty", + }) if err = preflightCmd.Execute(); err != nil { return fmt.Errorf("preflight failed: use --force to skip") } @@ -125,24 +134,31 @@ func setupFunc(args []string, opPubKey string, force bool, tags, skipTags string } extraVars := map[string]any{ - "appa_instance_domain": cfg.Domain, - "appa_version": vcs.Version(), - "cloudflare_token": cfg.CloudflareToken, - "smtp_host": cfg.SMTPHost, - "smtp_port": cfg.SMTPPort, - "smtp_username": cfg.SMTPUsername, - "smtp_password": cfg.SMTPPassword, - - "operator_user": map[string]any{ - "name": cfg.OperatorUser, - "ssh_keys": []string{opPubKey}, - }, + "appa_server_domain": cfg.Domain, + "appa_version": vcs.DockerTag(), + "cloudflare_token": cfg.CloudflareToken, + "smtp_host": cfg.SMTPHost, + "smtp_port": cfg.SMTPPort, + "smtp_username": cfg.SMTPUsername, + "smtp_password": cfg.SMTPPassword, + "deploy_user": map[string]any{ - "name": ansible.UserDeploy, - "ssh_keys": []string{opPubKey}, + "name": ansible.UserDeploy, + "groups": []string{"docker", "appa-admin"}, + "sudo": "ALL=(ALL) NOPASSWD: /usr/bin/systemctl, /usr/bin/docker", + "ssh_authorized_keys": splitKeys(opPubKey), }, } + if cfg.OperatorUser != "" { + extraVars["operator_user"] = map[string]any{ + "name": cfg.OperatorUser, + "groups": []string{"sudo", "appa-admin"}, + "sudo": "ALL=(ALL:ALL) ALL", + "ssh_authorized_keys": splitKeys(opPubKey), + } + } + if skipVerify { cfg.SSHSkipVerify = true } @@ -154,53 +170,46 @@ func setupFunc(args []string, opPubKey string, force bool, tags, skipTags string SkipTags: skipTags, } - output.Section("Applying security hardening") - secPlaybook := ansible.PlaybookPath("security-hardening.yml") - playbook.Name = secPlaybook - if err = ansible.RunPlaybook(playbook); err != nil { + playbook.Name = ansible.PlaybookPath("security-hardening.yml") + if err = runAnsible(playbook, "Applying security hardening", verbose); err != nil { return fmt.Errorf("apply security hardening: %w", err) } - output.Section("Deploying Appa Stack") cfg.SSHUser = ansible.UserDeploy - stackPlaybook := ansible.PlaybookPath("deploy-stack.yml") - playbook.Name = stackPlaybook - if err = ansible.RunPlaybook(playbook); err != nil { + playbook.Name = ansible.PlaybookPath("deploy-stack.yml") + if err = runAnsible(playbook, "Deploying Appa Stack", verbose); err != nil { return fmt.Errorf("deploy appa stack: %w", err) } output.Section("Waiting for Appa API to become reachable") - apiURL := fmt.Sprintf("https://%s", cfg.Domain) - if cfg.Domain == "" { - apiURL = fmt.Sprintf("http://%s", cfg.SSHHost) - } + apiURL := apiBaseURL(cfg) healthURL := apiURL + "/v1/healthcheck" if err = pollHealth(healthURL, 60*time.Second); err != nil { return fmt.Errorf("API health check failed: %w", err) } output.Success("Appa API is reachable at %s", healthURL) - cfg.BaseAPIURL = apiURL + cfg.APIBaseURL = apiURL cfg.SetupDone = true - if err = config.SaveInstance(cfg); err != nil { - return fmt.Errorf("save instance: %w", err) + if err = config.SaveServer(cfg); err != nil { + return fmt.Errorf("save server: %w", err) } output.Section("Setup Complete") - output.Success("Instance %q is ready!", name) + output.Success("Server %q is ready!", name) output.Success(" API URL: %s", healthURL) return nil } // applyFunc handles the logic for the apply command, ensuring connectivity before // re-running provisioning playbooks with specific tags. -func applyFunc(args []string, tags, skipTags string, skipVerify bool) error { +func applyFunc(args []string, tags, skipTags string, skipVerify, verbose bool) error { name := args[0] - if !config.InstanceExists(name) { + if !config.ServerExists(name) { return fmt.Errorf("%w: %s", errConfigNotFound, name) } - cfg, err := config.LoadInstance(name) + cfg, err := config.LoadServer(name) if err != nil { return err } @@ -234,13 +243,13 @@ func applyFunc(args []string, tags, skipTags string, skipVerify bool) error { } extraVars := map[string]any{ - "appa_instance_domain": cfg.Domain, - "appa_version": vcs.Version(), - "cloudflare_token": cfg.CloudflareToken, - "smtp_host": cfg.SMTPHost, - "smtp_port": cfg.SMTPPort, - "smtp_username": cfg.SMTPUsername, - "smtp_password": cfg.SMTPPassword, + "appa_server_domain": cfg.Domain, + "appa_version": vcs.DockerTag(), + "cloudflare_token": cfg.CloudflareToken, + "smtp_host": cfg.SMTPHost, + "smtp_port": cfg.SMTPPort, + "smtp_username": cfg.SMTPUsername, + "smtp_password": cfg.SMTPPassword, } playbook := ansible.Playbook{ @@ -250,23 +259,62 @@ func applyFunc(args []string, tags, skipTags string, skipVerify bool) error { SkipTags: skipTags, } - output.Section("Applying configuration to %q", name) - secPlaybook := ansible.PlaybookPath("security-hardening.yml") - playbook.Name = secPlaybook - if err := ansible.RunPlaybook(playbook); err != nil { + if verbose { + output.Section("Applying configuration to %q", name) + } + + playbook.Name = ansible.PlaybookPath("security-hardening.yml") + if err = runAnsible(playbook, "Applying security hardening", verbose); err != nil { return err } - stackPlaybook := ansible.PlaybookPath("deploy-stack.yml") - playbook.Name = stackPlaybook - if err := ansible.RunPlaybook(playbook); err != nil { + playbook.Name = ansible.PlaybookPath("deploy-stack.yml") + if err = runAnsible(playbook, "Deploying Appa Stack", verbose); err != nil { return err } - output.Success("Configuration applied to %q", name) + if !verbose { + output.Check("Configuration applied to %q", true, name) + } else { + output.Success("Configuration applied to %q", name) + } return nil } +// splitKeys returns a slice containing the given key, +// or an empty slice if the key is empty. +func splitKeys(s string) []string { + if s == "" { + return []string{} + } + return []string{s} +} + +// runAnsible runs an Ansible playbook, showing a spinner with the given +// label when not in verbose mode, or a section header + full output when verbose. +func runAnsible(p ansible.Playbook, label string, verbose bool) error { + if verbose { + output.Section("%s", label) + return ansible.RunPlaybook(p) + } + s := tui.StartSpinner(label) + p.Quiet = true + err := ansible.RunPlaybook(p) + s.Stop(err == nil) + return err +} + +// apiBaseURL returns the API base URL for health checks and config storage. +func apiBaseURL(cfg config.ServerConfig) string { + if cfg.Domain != "" { + return fmt.Sprintf("https://%s", cfg.Domain) + } + if cfg.APIPort > 0 { + return fmt.Sprintf("http://%s:%d", cfg.SSHHost, cfg.APIPort) + } + return fmt.Sprintf("http://%s", cfg.SSHHost) +} + // pollHealth repeatedly checks the Appa API health endpoint until it returns 200 OK // or the timeout is reached. func pollHealth(url string, timeout time.Duration) error { diff --git a/internal/cli/config/config.go b/internal/cli/config/config.go index 8ef21f2..3826c32 100644 --- a/internal/cli/config/config.go +++ b/internal/cli/config/config.go @@ -12,6 +12,8 @@ import ( "github.com/BurntSushi/toml" "github.com/theolujay/appa/internal/cli/output" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) func ValidName(s string) bool { @@ -27,7 +29,7 @@ func BasePath(p string) (string, error) { return filepath.Base(path), err } -type InstanceConfig struct { +type ServerConfig struct { Name string `toml:"name"` SSHHost string `toml:"ssh_host"` SSHUser string `toml:"ssh_user"` @@ -42,7 +44,8 @@ type InstanceConfig struct { SMTPUsername string `toml:"smtp_username"` SMTPPassword string `toml:"smtp_password"` SetupDone bool `toml:"setup_done"` - BaseAPIURL string `toml:"base_api_url,omitempty"` + APIBaseURL string `toml:"api_base_url,omitempty"` + APIPort int `toml:"api_port,omitempty"` } type ProjectConfig struct { @@ -54,13 +57,13 @@ type ProjectConfig struct { type Kind string const ( - Instance Kind = "instance" - Project Kind = "project" + Server Kind = "server" + Project Kind = "project" ) -const ServerDeployDir = "/opt/appa/builds" +const ServerRemoteDir = "/opt/appa/builds" -func DefaultInstance(name string) InstanceConfig { - return InstanceConfig{ +func DefaultServer(name string) ServerConfig { + return ServerConfig{ Name: name, SSHUser: "root", SSHPort: 22, @@ -76,8 +79,8 @@ func DefaultProject(name, source string) ProjectConfig { } var kindDirs = map[Kind]string{ - Instance: "instances", - Project: "projects", + Server: "servers", + Project: "projects", } func baseDir() string { @@ -96,7 +99,13 @@ func PathFor(k Kind, name string) string { return filepath.Join(dirFor(k, name), "config.toml") } -func load[T InstanceConfig | ProjectConfig](path string) (T, error) { +func renameDirFor(k Kind, old, new string) error { + oldPath := dirFor(k, old) + newPath := filepath.Join(filepath.Dir(oldPath), new) + return os.Rename(oldPath, newPath) +} + +func load[T ServerConfig | ProjectConfig](path string) (T, error) { var cfg T _, err := toml.DecodeFile(path, &cfg) if err != nil { @@ -105,8 +114,8 @@ func load[T InstanceConfig | ProjectConfig](path string) (T, error) { return cfg, nil } -func LoadInstance(name string) (InstanceConfig, error) { - return load[InstanceConfig](PathFor(Instance, name)) +func LoadServer(name string) (ServerConfig, error) { + return load[ServerConfig](PathFor(Server, name)) } func LoadProject(name string) (ProjectConfig, error) { @@ -122,12 +131,12 @@ func writeTOML(path string, v any) error { return toml.NewEncoder(f).Encode(v) } -func SaveInstance(cfg InstanceConfig) error { - dir := dirFor(Instance, cfg.Name) +func SaveServer(cfg ServerConfig) error { + dir := dirFor(Server, cfg.Name) if err := os.MkdirAll(dir, 0700); err != nil { - return fmt.Errorf("create instance dir: %w", err) + return fmt.Errorf("create server dir: %w", err) } - return writeTOML(PathFor(Instance, cfg.Name), cfg) + return writeTOML(PathFor(Server, cfg.Name), cfg) } func SaveProject(cfg ProjectConfig) error { @@ -138,21 +147,21 @@ func SaveProject(cfg ProjectConfig) error { return writeTOML(PathFor(Project, cfg.Name), cfg) } -func ListInstances() ([]InstanceConfig, error) { - d := filepath.Join(baseDir(), kindDirs[Instance]) +func ListServers() ([]ServerConfig, error) { + d := filepath.Join(baseDir(), kindDirs[Server]) entries, err := os.ReadDir(d) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } - return nil, fmt.Errorf("list instances: %w", err) + return nil, fmt.Errorf("list servers: %w", err) } - var cfgs []InstanceConfig + var cfgs []ServerConfig for _, e := range entries { if !e.IsDir() { continue } - cfg, err := LoadInstance(e.Name()) + cfg, err := LoadServer(e.Name()) if err != nil { continue } @@ -184,11 +193,11 @@ func ListProjects() ([]ProjectConfig, error) { return cfgs, nil } -func InstanceExists(name string) bool { +func ServerExists(name string) bool { if !ValidName(name) { return false } - _, err := os.Stat(PathFor(Instance, name)) + _, err := os.Stat(PathFor(Server, name)) return err == nil } @@ -200,7 +209,7 @@ func ProjectExists(name string) bool { return err == nil } -func validateInstance(cfg InstanceConfig) error { +func validateServer(cfg ServerConfig) error { var errs []error if !ValidName(cfg.Name) { errs = append(errs, fmt.Errorf("invalid name %q", cfg.Name)) @@ -228,8 +237,8 @@ func validateProject(cfg ProjectConfig) error { if cfg.Source == "" { errs = append(errs, fmt.Errorf("source is required")) } - if cfg.Target != "" && !InstanceExists(cfg.Target) { - errs = append(errs, fmt.Errorf("target instance %q not found", cfg.Target)) + if cfg.Target != "" && !ServerExists(cfg.Target) { + errs = append(errs, fmt.Errorf("target server %q not found", cfg.Target)) } return errors.Join(errs...) } @@ -284,11 +293,11 @@ func Edit(kind Kind, name string) error { path := PathFor(kind, name) output.Section("Waiting for your editor to close %q config file...", name) - var instanceCfg InstanceConfig + var serverCfg ServerConfig var projectCfg ProjectConfig switch kind { - case Instance: - instanceCfg, err = LoadInstance(name) + case Server: + serverCfg, err = LoadServer(name) if err != nil { return err } @@ -310,18 +319,29 @@ func Edit(kind Kind, name string) error { } var vErr error + var oldName string switch kind { - case Instance: - var cfg InstanceConfig - cfg, err = LoadInstance(name) + case Server: + var cfg ServerConfig + cfg, err = LoadServer(name) if err == nil { - vErr = validateInstance(cfg) + vErr = validateServer(cfg) + if vErr == nil && serverCfg.Name != cfg.Name { + vErr = renameDirFor(kind, name, cfg.Name) + oldName = name + name = cfg.Name + } } case Project: var cfg ProjectConfig cfg, err = LoadProject(name) if err == nil { vErr = validateProject(cfg) + if vErr == nil && projectCfg.Name != cfg.Name { + vErr = renameDirFor(kind, name, cfg.Name) + oldName = name + name = cfg.Name + } } } if vErr != nil { @@ -331,10 +351,12 @@ func Edit(kind Kind, name string) error { if err != nil { var rErr error switch kind { - case Instance: - rErr = SaveInstance(instanceCfg) + case Server: + rErr = SaveServer(serverCfg) + name = serverCfg.Name case Project: rErr = SaveProject(projectCfg) + name = projectCfg.Name } if rErr != nil { return fmt.Errorf("failed to revert changes to %s %s config: %w", name, string(kind), rErr) @@ -349,13 +371,28 @@ func Edit(kind Kind, name string) error { continue } - output.Success("Config %q updated", name) + if oldName != "" { + output.Success( + "%s config %q updated and renamed to %q", + cases.Title(language.English).String(string(kind)), + oldName, + name, + ) + } else { + output.Success( + "%s config %q updated", + cases.Title(language.English).String(string(kind)), + name, + ) + } return nil } } -func ServerDirFor(name string) string { - return filepath.Join(ServerDeployDir, name) + +func ServerRemoteDirFor(name string) string { + return filepath.Join(ServerRemoteDir, name) } + func ParseProjectSource(c string) (string, error) { if !strings.HasPrefix(c, "/") { diff --git a/internal/cli/output/output.go b/internal/cli/output/output.go index 087cb8f..8e109ea 100644 --- a/internal/cli/output/output.go +++ b/internal/cli/output/output.go @@ -2,85 +2,160 @@ package output import ( "fmt" + "image/color" "os" "strings" - "text/tabwriter" + + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" ) -// PrintTable prints a formatted table to standard output using a tabwriter. -// -// Example output: -// -// ` -// NAME HOST STATUS -// personal root@203.0.113.10 done -// staging root@198.51.100.20 host set -// ` -func PrintTable(header []string, rows [][]string) { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) - fmt.Fprintln(w, strings.Join(header, "\t")) - for _, row := range rows { - fmt.Fprintln(w, strings.Join(row, "\t")) - } - w.Flush() +var ( + Purple = lipgloss.Color("#7D56F4") + Green = lipgloss.Color("#43BF6D") + Red = lipgloss.Color("#FF5555") + Yellow = lipgloss.Color("#F1FA8C") + Cyan = lipgloss.Color("#8BE9FD") + Gray = lipgloss.Color("#6272A4") + White = lipgloss.Color("#FAFAFA") + DarkBg = lipgloss.Color("#1A1A2E") + DarkCard = lipgloss.Color("#16213E") + DraculaBg = lipgloss.Color("#282A36") + DraculaFg = lipgloss.Color("#F8F8F2") +) + +var ( + SectionStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(Purple) + + SuccessStyle = lipgloss.NewStyle(). + Foreground(Green) + + ErrorLabelStyle = lipgloss.NewStyle(). + Background(Red). + Foreground(White). + Bold(true). + Padding(0, 1) + + WarnStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(Yellow) + + SubtleStyle = lipgloss.NewStyle(). + Foreground(Gray) + + BadgeStyle = lipgloss.NewStyle(). + Foreground(White). + Bold(true). + Padding(0, 1) +) + +func StatusBadge(text string, c color.Color) string { + return BadgeStyle.Background(c).Render(text) } -// Check prints a status check line. It displays a checkmark (✓) if ok is true, -// and a cross (✗) otherwise. The label supports fmt-style formatting. -// -// Example output: -// -// ` -// ✓ SSH target -// ✗ connection timed out -// ` func Check(label string, ok bool, args ...any) { s := fmt.Sprintf(label, args...) if ok { - fmt.Printf("✓ %s\n", s) + lipgloss.Printf("%s %s\n", BoldGreen("✓"), s) } else { - fmt.Printf("✗ %s\n", s) + lipgloss.Printf("%s %s\n", BoldRed("✗"), s) } } -// Warn prints a warning message to standard output, prefixed with "! ". -// -// Example output: -// -// `! Disk usage is high: 90%` func Warn(format string, args ...any) { - fmt.Printf("! "+format+"\n", args...) + lipgloss.Printf("%s\n", WarnStyle.Render("! "+fmt.Sprintf(format, args...))) } -// Section prints a section header with an underline for visual separation in CLI output. -// -// Example output: -// -// ` -// Deployment Status -// ----------------- -// ` func Section(format string, args ...any) { title := fmt.Sprintf(format, args...) - fmt.Println() - fmt.Println(title) - fmt.Println(strings.Repeat("-", len(title))) + lipgloss.Println() + lipgloss.Println(SectionStyle.Render(title)) + lipgloss.Println(SubtleStyle.Render(strings.Repeat("─", len(title)))) } -// Error prints a formatted error message to standard error, prefixed with "Error: ". -// -// Example output: -// -// `Error: Failed to connect: connection refused` func Error(format string, args ...any) { - fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...) + lipgloss.Fprintf(os.Stderr, "%s %s\n", + ErrorLabelStyle.Render("Error"), + fmt.Sprintf(format, args...)) } -// Success prints a formatted success message to standard output. -// -// Example output: -// -// `Instance "personal" created` func Success(format string, args ...any) { - fmt.Fprintf(os.Stdout, format+"\n", args...) + lipgloss.Println(SuccessStyle.Render(fmt.Sprintf(format, args...))) +} + +func PrintTable(header []string, rows [][]string, dimmed []bool) { + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(Gray)). + StyleFunc(func(row, col int) lipgloss.Style { + switch { + case row == table.HeaderRow: + return lipgloss.NewStyle(). + Bold(true). + Foreground(Purple). + Align(lipgloss.Center). + Padding(0, 1) + case dimmed != nil && dimmed[row]: + return lipgloss.NewStyle(). + Foreground(Gray). + Padding(0, 1) + default: + return lipgloss.NewStyle().Padding(0, 1) + } + + }). + Headers(header...). + Rows(rows...) + lipgloss.Println(t) +} + +func Header(title string) { + Section("%s", title) +} + +func Panel(header, body string) { + top := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder(), true, false). + BorderForeground(Purple). + Foreground(Purple). + Bold(true). + Padding(0, 1). + Width(80). + Render(header) + content := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder(), false, true, true, true). + BorderForeground(Purple). + Padding(0, 2). + Width(78). + Render(body) + lipgloss.Printf("%s\n%s\n\n", top, content) +} + +func KV(key, value string) { + k := lipgloss.NewStyle().Foreground(Purple).Bold(true).Render(key) + sep := SubtleStyle.Render(" • ") + lipgloss.Printf(" %s %s %s\n", k, sep, value) +} + +func Step(n int, label string) { + num := lipgloss.NewStyle(). + Foreground(White). + Background(Purple). + Padding(0, 1). + Render(fmt.Sprintf(" %d ", n)) + lipgloss.Printf("%s %s\n", num, label) +} + +func BoldGreen(s string) string { + return lipgloss.NewStyle().Foreground(Green).Bold(true).Render(s) +} + +func BoldRed(s string) string { + return lipgloss.NewStyle().Foreground(Red).Bold(true).Render(s) +} + +func Faint(s string) string { + return lipgloss.NewStyle().Foreground(Gray).Render(s) } diff --git a/internal/cli/tui/logs.go b/internal/cli/tui/logs.go new file mode 100644 index 0000000..9b4a360 --- /dev/null +++ b/internal/cli/tui/logs.go @@ -0,0 +1,302 @@ +package tui + +import ( + "fmt" + "image/color" + "net/url" + "strings" + "sync" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/gorilla/websocket" + + "github.com/theolujay/appa/internal/hub" +) + +var phaseColors = map[string]color.Color{ + "prepare": lipgloss.Color("#8BE9FD"), + "build": lipgloss.Color("#F1FA8C"), + "deploy": lipgloss.Color("#50FA7B"), + "routing": lipgloss.Color("#FF79C6"), +} + +type logModel struct { + lines []string + autoScroll bool + scrollOff int + width int + height int + err error + status string + + conn *websocket.Conn + connMu sync.Mutex + done chan struct{} + + deploymentID int64 + apiURL string +} + +func NewLogViewer(apiURL string, deploymentID int64) tea.Model { + return &logModel{ + apiURL: apiURL, + deploymentID: deploymentID, + autoScroll: true, + done: make(chan struct{}), + } +} + +func (m *logModel) Init() tea.Cmd { + return tea.Batch(m.connectWS, tea.Tick(5*time.Second, m.connTimeout)) +} + +func (m *logModel) connectWS() tea.Msg { + u, err := url.Parse(m.apiURL) + if err != nil { + return errMsg{Err: fmt.Errorf("invalid API URL: %w", err)} + } + scheme := "ws" + if u.Scheme == "https" { + scheme = "wss" + } + wsURL := fmt.Sprintf("%s://%s/v1/deployments/%d/logs", scheme, u.Host, m.deploymentID) + + c, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return errMsg{Err: fmt.Errorf("dial: %w", err)} + } + + m.connMu.Lock() + m.conn = c + m.connMu.Unlock() + + return connectedMsg{} +} + +func (m *logModel) connTimeout(t time.Time) tea.Msg { + m.connMu.Lock() + conn := m.conn + m.connMu.Unlock() + if conn == nil { + return errMsg{Err: fmt.Errorf("timed out connecting to log stream")} + } + return nil +} + +func (m *logModel) readPump() tea.Msg { + m.connMu.Lock() + conn := m.conn + m.connMu.Unlock() + if conn == nil { + return nil + } + + var evt hub.Event + if err := conn.ReadJSON(&evt); err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + return streamEndedMsg{} + } + return errMsg{Err: fmt.Errorf("read: %w", err)} + } + + switch evt.Type { + case hub.MessageTypeLog: + return logLineMsg{ + line: evt.Log.Line, + phase: evt.Log.Phase, + } + case hub.MessageTypeStatus: + line := evt.Status.Status + if evt.Status.URL != "" { + line = fmt.Sprintf("%s — %s", evt.Status.Status, evt.Status.URL) + } + return logLineMsg{line: line, phase: "status"} + } + return m.readPump() +} + +type connectedMsg struct{} + +type logLineMsg struct { + line string + phase string +} + +type streamEndedMsg struct{} + +func (m *logModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height - 3 + if m.scrollOff > 0 && m.autoScroll { + m.scrollOff = max(0, len(m.lines)-m.height) + } + + case tea.KeyPressMsg: + switch msg.String() { + case "q", "ctrl+c": + m.closeConn() + return m, tea.Quit + case "f": + m.autoScroll = !m.autoScroll + if m.autoScroll { + m.scrollOff = max(0, len(m.lines)-m.height) + } + case "up", "k": + if m.scrollOff > 0 { + m.scrollOff-- + m.autoScroll = false + } + case "down", "j": + if m.scrollOff < len(m.lines)-m.height { + m.scrollOff++ + } + case "pageup", "b": + half := m.height / 2 + m.scrollOff = max(0, m.scrollOff-half) + m.autoScroll = false + case "pagedown", "space": + half := m.height / 2 + m.scrollOff = min(len(m.lines)-m.height, m.scrollOff+half) + case "g": + m.scrollOff = 0 + m.autoScroll = false + case "G": + m.scrollOff = max(0, len(m.lines)-m.height) + m.autoScroll = true + } + + case connectedMsg: + return m, m.readPump + + case logLineMsg: + styled := styleLogLine(msg.line, msg.phase) + m.lines = append(m.lines, styled) + if m.autoScroll { + m.scrollOff = max(0, len(m.lines)-m.height) + } + return m, m.readPump + + case streamEndedMsg: + m.status = "stream ended" + + case errMsg: + m.err = msg.Err + m.closeConn() + return m, tea.Quit + } + + return m, nil +} + +func (m *logModel) View() tea.View { + view := tea.NewView(m.renderView()) + view.AltScreen = true + view.MouseMode = tea.MouseModeCellMotion + return view +} + +func (m *logModel) renderView() string { + var b strings.Builder + + if len(m.lines) == 0 && m.err == nil && m.status == "" { + b.WriteString("Connecting to log stream...\n") + } + + visible := m.lines + if len(m.lines) > m.height { + start := len(m.lines) - m.height + if m.scrollOff < len(m.lines)-m.height { + start = m.scrollOff + } + end := start + m.height + if end > len(m.lines) { + end = len(m.lines) + } + visible = m.lines[start:end] + } + + for _, l := range visible { + b.WriteString(l) + b.WriteByte('\n') + } + + b.WriteString(m.renderFooter()) + + return b.String() +} + +func (m *logModel) renderFooter() string { + var statusDot string + switch { + case m.err != nil: + statusDot = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Render("●") + case m.status == "stream ended": + statusDot = lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")).Render("●") + default: + statusDot = lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")).Render("●") + } + + statusText := "streaming" + if m.err != nil { + statusText = fmt.Sprintf("error: %s", m.err) + } else if m.status != "" { + statusText = m.status + } + + followDot := " " + if m.autoScroll { + followDot = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#50FA7B")). + Bold(true). + Render("●") + } + + help := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6272A4")). + Render(" [q] quit [f] follow [↑↓/j k] scroll [g/G] top/bottom ") + + info := fmt.Sprintf("%s %s %s follow\n%s", statusDot, statusText, followDot, help) + + return lipgloss.NewStyle(). + Width(m.width). + Padding(0, 1). + Background(lipgloss.Color("#282A36")). + Render(info) +} + +func styleLogLine(line, phase string) string { + st := lipgloss.NewStyle() + switch phase { + case "prepare": + st = st.Foreground(phaseColors["prepare"]) + case "build": + st = st.Foreground(phaseColors["build"]) + case "deploy": + st = st.Foreground(phaseColors["deploy"]) + case "routing": + st = st.Foreground(phaseColors["routing"]) + case "status": + st = st.Bold(true).Foreground(lipgloss.Color("#FFB86C")) + default: + st = st.Foreground(lipgloss.Color("#F8F8F2")) + } + return st.Render(line) +} + +func (m *logModel) closeConn() { + select { + case <-m.done: + default: + close(m.done) + } + m.connMu.Lock() + if m.conn != nil { + m.conn.Close() + m.conn = nil + } + m.connMu.Unlock() +} diff --git a/internal/cli/tui/preflight.go b/internal/cli/tui/preflight.go new file mode 100644 index 0000000..3813878 --- /dev/null +++ b/internal/cli/tui/preflight.go @@ -0,0 +1,267 @@ +package tui + +import ( + "fmt" + "image/color" + "math" + "strings" + "sync" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/harmonica" +) + +type checkState int + +const ( + checkPending checkState = iota + checkRunning + checkOK + checkFail + checkWarn +) + +type Check struct { + Label string + Fn func() (ok bool, info string, warn bool) +} + +type checkItem struct { + label string + state checkState + info string + fn func() (ok bool, info string, warn bool) +} + +type PreflightModel struct { + checks []checkItem + current int + width int + height int + done bool + Failures int + Warnings int + + // protect concurrent access to fields that may be read from Render + mu sync.RWMutex + + spring harmonica.Spring + progress float64 + velocity float64 + target float64 + + // cached styles to avoid allocating on every render + titleStyle lipgloss.Style + subtitleStyle lipgloss.Style + numStyle lipgloss.Style + labelStyle lipgloss.Style + pendingStyle lipgloss.Style + runningStyle lipgloss.Style + okStyle lipgloss.Style + failStyle lipgloss.Style + warnStyle lipgloss.Style + infoStyle lipgloss.Style +} + +func NewPreflightModel(checks []Check) *PreflightModel { + return &PreflightModel{ + checks: func() []checkItem { + items := make([]checkItem, len(checks)) + for i, c := range checks { + items[i] = checkItem{label: c.Label, state: checkPending, fn: c.Fn} + } + return items + }(), + spring: harmonica.NewSpring(harmonica.FPS(60), 8.0, 0.4), + titleStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7D56F4")), + subtitleStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")), + numStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")), + labelStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#F8F8F2")), + pendingStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")), + runningStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#8BE9FD")), + okStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")).Bold(true), + failStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Bold(true), + warnStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#F1FA8C")).Bold(true), + infoStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")), + } +} + +type checkResult struct { + index int + ok bool + info string + warn bool +} + +func (m *PreflightModel) Init() tea.Cmd { + return tea.Batch(m.nextCheck(), m.springTick()) +} + +func (m *PreflightModel) springTick() tea.Cmd { + return tea.Tick(time.Second/60, func(t time.Time) tea.Msg { + return springTickMsg(t) + }) +} + +type springTickMsg time.Time + +func (m *PreflightModel) nextCheck() tea.Cmd { + m.mu.Lock() + if m.current >= len(m.checks) { + m.done = true + m.mu.Unlock() + return nil + } + m.checks[m.current].state = checkRunning + m.target = float64(m.current + 1) + + idx := m.current + m.mu.Unlock() + return func() tea.Msg { + // run check outside of locks + ok, info, warn := m.checks[idx].fn() + return checkResult{index: idx, ok: ok, info: info, warn: warn} + } +} + +func (m *PreflightModel) completeCheck(index int, ok bool, info string, warn bool) { + m.mu.Lock() + m.checks[index].state = checkOK + m.checks[index].info = info + if !ok { + m.checks[index].state = checkFail + m.Failures++ + } else if warn { + m.checks[index].state = checkWarn + m.Warnings++ + } + m.current++ + m.mu.Unlock() +} + +func (m *PreflightModel) 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.KeyPressMsg: + switch msg.String() { + case "q", "ctrl+c", "enter", "esc": + return m, tea.Quit + } + + case springTickMsg: + m.progress, m.velocity = m.spring.Update(m.progress, m.velocity, m.target) + if m.progress < m.target || !m.done { + return m, m.springTick() + } + return m, nil + + case checkResult: + m.completeCheck(msg.index, msg.ok, msg.info, msg.warn) + return m, m.nextCheck() + } + + return m, nil +} + +func (m *PreflightModel) View() tea.View { + var b strings.Builder + + // snapshot state under read lock so rendering can proceed without holding locks + m.mu.RLock() + checks := make([]checkItem, len(m.checks)) + copy(checks, m.checks) + done := m.done + progress := m.progress + failures := m.Failures + warnings := m.Warnings + // styles are immutable + title := m.titleStyle.Render("Preflight Checks") + subtitle := m.subtitleStyle.Render("Press q to quit") + m.mu.RUnlock() + + b.WriteString(lipgloss.JoinVertical(lipgloss.Left, title, subtitle)) + b.WriteByte('\n') + + for i, c := range checks { + b.WriteString(m.renderCheck(i, c, progress)) + b.WriteByte('\n') + } + + b.WriteByte('\n') + if done { + // renderSummary needs failures/warnings snapshot + b.WriteString(m.renderSummaryWith(failures, warnings)) + } + + v := tea.NewView(b.String()) + v.AltScreen = true + return v +} + +func (m *PreflightModel) renderCheck(i int, c checkItem, progress float64) string { + idx := i + 1 + + var icon, status string + switch c.state { + case checkPending: + icon = m.pendingStyle.Render("○") + status = m.pendingStyle.Render("pending") + case checkRunning: + amount := math.Mod(progress-float64(i), 1.0) + if amount < 0 { + amount += 1.0 + } + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + frame := frames[int(amount*float64(len(frames)))%len(frames)] + icon = m.runningStyle.Render(frame) + status = m.runningStyle.Render("checking") + case checkOK: + icon = m.okStyle.Render("✓") + status = m.okStyle.Bold(false).Render("passed") + case checkFail: + icon = m.failStyle.Render("✗") + status = m.failStyle.Bold(false).Render("failed") + case checkWarn: + icon = m.warnStyle.Render("!") + status = m.warnStyle.Bold(false).Render("warning") + } + + num := m.numStyle.Render(fmt.Sprintf("%02d", idx)) + + label := m.labelStyle.Render(c.label) + + extra := "" + if c.info != "" { + extra = " " + m.infoStyle.Render(c.info) + } + + return fmt.Sprintf(" %s %s %s%s %s", num, icon, label, extra, status) +} + +// renderSummaryWith renders summary using provided snapshots to avoid locking inside +func (m *PreflightModel) renderSummaryWith(failures, warnings int) string { + var clr color.Color + var label string + switch { + case failures > 0: + clr = lipgloss.Color("#FF5555") + label = fmt.Sprintf("✗ %d failure(s)", failures) + if warnings > 0 { + label += fmt.Sprintf(", %d warning(s)", warnings) + } + case warnings > 0: + clr = lipgloss.Color("#F1FA8C") + label = fmt.Sprintf("✓ All critical checks passed (%d warning(s))", warnings) + default: + clr = lipgloss.Color("#50FA7B") + label = "✓ All checks passed" + } + + style := lipgloss.NewStyle().Bold(true).Foreground(clr).Padding(0, 2).Border(lipgloss.RoundedBorder()).BorderForeground(clr) + return style.Render(label) +} diff --git a/internal/cli/tui/runner.go b/internal/cli/tui/runner.go new file mode 100644 index 0000000..1503491 --- /dev/null +++ b/internal/cli/tui/runner.go @@ -0,0 +1,27 @@ +package tui + +import ( + "os" + + tea "charm.land/bubbletea/v2" +) + +func NewProgram(model tea.Model, opts ...tea.ProgramOption) *tea.Program { + return tea.NewProgram(model, opts...) +} + +func Run(model tea.Model) error { + p := NewProgram(model) + _, err := p.Run() + return err +} + +func LogToFile(path string) { + if _, err := tea.LogToFile(path, "appa-tui"); err != nil { + os.Stderr.WriteString("failed to write log: " + err.Error() + "\n") + } +} + +type errMsg struct{ Err error } + +func (e errMsg) Error() string { return e.Err.Error() } diff --git a/internal/cli/tui/spinner.go b/internal/cli/tui/spinner.go new file mode 100644 index 0000000..4d228ee --- /dev/null +++ b/internal/cli/tui/spinner.go @@ -0,0 +1,56 @@ +package tui + +import ( + "fmt" + "os" + "time" + + "github.com/theolujay/appa/internal/cli/output" +) + +var spinFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +type Spinner struct { + label string + stopCh chan struct{} + doneCh chan struct{} + ok bool +} + +func StartSpinner(label string) *Spinner { + s := &Spinner{ + label: label, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } + go s.run() + return s +} + +func (s *Spinner) run() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + i := 0 + for { + select { + case <-s.stopCh: + fmt.Fprintf(os.Stdout, "\r\033[K") + if s.ok { + fmt.Fprintf(os.Stdout, "%s %s\n", output.BoldGreen("✓"), s.label) + } else { + fmt.Fprintf(os.Stdout, "%s %s\n", output.BoldRed("✗"), s.label) + } + close(s.doneCh) + return + case <-ticker.C: + fmt.Fprintf(os.Stdout, "\r%s %s\033[K", spinFrames[i], s.label) + i = (i + 1) % len(spinFrames) + } + } +} + +func (s *Spinner) Stop(ok bool) { + s.ok = ok + close(s.stopCh) + <-s.doneCh +} diff --git a/internal/data/deployments.go b/internal/data/deployments.go index 6d739ad..dec02fb 100644 --- a/internal/data/deployments.go +++ b/internal/data/deployments.go @@ -18,6 +18,7 @@ type DeploymentModel struct { type Deployment struct { ID int64 `json:"id"` UserID *int64 `json:"user_id"` + ProjectID *int64 `json:"project_id,omitempty"` Source string `json:"source"` Status string `json:"status"` ImageTag *string `json:"image_tag"` @@ -41,7 +42,7 @@ type DeploymentUpdate struct { type DeploymentModeler interface { Create(d *Deployment) error Get(id int64) (*Deployment, error) - GetAllForUser(id int64, status string, filters Filters) ( + GetAllForUser(id int64, status string, projectID int64, filters Filters) ( []Deployment, Metadata, error, ) GetLogs(id int64) ([]LogEntry, error) @@ -49,7 +50,7 @@ type DeploymentModeler interface { UpdateAndGet(id int64, u DeploymentUpdate) (*Deployment, error) } -func ValidateDeployment(d *Deployment) error { +func ValidateDeployment(d Deployment) error { v := vd.New() v.Check(d.Source != "", "source", "must be provided") v.Check(len(d.Source) <= 500, "source", "must not be more than 500 bytes long") @@ -76,8 +77,8 @@ func validateEnvVars(e string) int { func (dm *DeploymentModel) Create(d *Deployment) error { query := ` - INSERT INTO deployments (source, env_vars, user_id) - VALUES($1, $2, $3) + INSERT INTO deployments (source, env_vars, user_id, project_id) + VALUES($1, $2, $3, $4) RETURNING id, status, created_at, version ` @@ -86,6 +87,7 @@ func (dm *DeploymentModel) Create(d *Deployment) error { d.Source, d.EnvVars, d.UserID, + d.ProjectID, ).Scan( &d.ID, &d.Status, @@ -93,13 +95,16 @@ func (dm *DeploymentModel) Create(d *Deployment) error { &d.Version, ) - return fmt.Errorf("deployments.create: %w", err) + if err != nil { + return fmt.Errorf("deployments.create: %w", err) + } + return nil } func (dm *DeploymentModel) Get(id int64) (*Deployment, error) { query := ` - SELECT id, user_id, source, status, image_tag, address, env_vars, url, created_at + SELECT id, user_id, project_id, source, status, image_tag, address, env_vars, url, created_at FROM deployments WHERE id = $1 ` ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) @@ -110,6 +115,7 @@ func (dm *DeploymentModel) Get(id int64) (*Deployment, error) { err := dm.DB.QueryRowContext(ctx, query, id).Scan( &d.ID, &d.UserID, + &d.ProjectID, &d.Source, &d.Status, &d.ImageTag, @@ -132,7 +138,7 @@ func (dm *DeploymentModel) Get(id int64) (*Deployment, error) { } func (dm *DeploymentModel) GetAllForUser( - id int64, status string, filters Filters, + id int64, status string, projectID int64, filters Filters, ) ([]Deployment, Metadata, error) { totalRecords := 0 md := Metadata{} @@ -140,19 +146,20 @@ func (dm *DeploymentModel) GetAllForUser( deployments := make([]Deployment, 0, filters.limit()) query := fmt.Sprintf(` - SELECT count(*) OVER(), id, user_id, source, status, + SELECT count(*) OVER(), id, user_id, project_id, source, status, image_tag, address, env_vars, url, created_at, version FROM deployments WHERE (user_id = $1 OR ($1 = 0 AND user_id IS NULL)) AND (LOWER(status) = LOWER($2) OR $2 = '') + AND (project_id = $3 OR $3 = 0) ORDER BY %s %s, id ASC - LIMIT $3 OFFSET $4 + LIMIT $4 OFFSET $5 `, filters.sortColumn(), filters.sortDirection()) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - args := []any{id, status, filters.limit(), filters.offset()} + args := []any{id, status, projectID, filters.limit(), filters.offset()} rows, err := dm.DB.QueryContext(ctx, query, args...) if err != nil { @@ -170,6 +177,7 @@ func (dm *DeploymentModel) GetAllForUser( &totalRecords, &d.ID, &d.UserID, + &d.ProjectID, &d.Source, &d.Status, &d.ImageTag, @@ -276,7 +284,7 @@ func (dm *DeploymentModel) UpdateAndGet(id int64, u DeploymentUpdate) (*Deployme query += strings.Join(fields, ", ") query += fmt.Sprintf(" WHERE id = $%d", len(args)+1) - query += " RETURNING id, user_id, source, status, image_tag, address, env_vars, url, created_at" + query += " RETURNING id, user_id, project_id, source, status, image_tag, address, env_vars, url, created_at" args = append(args, id) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) @@ -287,6 +295,7 @@ func (dm *DeploymentModel) UpdateAndGet(id int64, u DeploymentUpdate) (*Deployme err := dm.DB.QueryRowContext(ctx, query, args...).Scan( &d.ID, &d.UserID, + &d.ProjectID, &d.Source, &d.Status, &d.ImageTag, diff --git a/internal/data/models.go b/internal/data/models.go index 05ae66a..be4bd93 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -7,17 +7,28 @@ package data import ( "database/sql" "errors" + + "github.com/lib/pq" ) var ( - ErrRecordNotFound = errors.New("record not found") - ErrEditConflict = errors.New("edit conflict") + ErrDuplicateEmail = errors.New("duplicate email") + ErrDuplicateProject = errors.New("duplicate project") + ErrEditConflict = errors.New("edit conflict") + ErrRecordNotFound = errors.New("record not found") +) + +const ( + pqUniqueViolation = "23505" + pqUsersEmailKey = "users_email_key" + pqProjectsNameKey = "projects_name_key" ) type Models struct { Deployments DeploymentModeler Users UserModeler Tokens TokenModeler + Projects ProjectModeler } func NewModels(db *sql.DB) Models { @@ -25,5 +36,14 @@ func NewModels(db *sql.DB) Models { Deployments: &DeploymentModel{DB: db}, Users: &UserModel{DB: db}, Tokens: &TokenModel{DB: db}, + Projects: &ProjectModel{DB: db}, + } +} + +func isUniqueViolation(err error, constraint string) bool { + pqErr := &pq.Error{} + if errors.As(err, &pqErr) { + return pqErr.Code == pqUniqueViolation && pqErr.Constraint == constraint } + return false } diff --git a/internal/data/projects.go b/internal/data/projects.go new file mode 100644 index 0000000..1024bc1 --- /dev/null +++ b/internal/data/projects.go @@ -0,0 +1,212 @@ +package data + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +type Project struct { + ID int64 `json:"id"` + Name string `json:"name"` + UserID *int64 `json:"user_id,omitempty"` + Version int `json:"-"` + CreatedAt time.Time `json:"created_at"` +} + +type ProjectModel struct { + DB *sql.DB +} + +type ProjectModeler interface { + Insert(p *Project) error + Get(id int64) (*Project, error) + GetByName(name string) (*Project, error) + GetAllForUser(id int64, filters Filters) ([]Project, Metadata, error) + Update(p *Project) error + Delete(id int64) error +} + +func (pm *ProjectModel) Insert(p *Project) error { + query := ` + INSERT INTO projects (name, user_id) + VALUES ($1, $2) + RETURNING id, version, created_at + ` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := pm.DB.QueryRowContext(ctx, query, p.Name, p.UserID).Scan( + &p.ID, + &p.Version, + &p.CreatedAt, + ) + if err != nil { + switch { + case isUniqueViolation(err, pqProjectsNameKey): + return ErrDuplicateProject + default: + return fmt.Errorf("projects.insert: %w", err) + } + } + return nil +} + +func (pm *ProjectModel) Get(id int64) (*Project, error) { + query := ` + SELECT id, name, user_id, version, created_at + FROM projects WHERE id = $1 + ` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + var p Project + err := pm.DB.QueryRowContext(ctx, query, id).Scan( + &p.ID, + &p.Name, + &p.UserID, + &p.Version, + &p.CreatedAt, + ) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrRecordNotFound + default: + return nil, fmt.Errorf("projects.get: %w", err) + } + } + return &p, nil +} + +func (pm *ProjectModel) GetByName(name string) (*Project, error) { + query := ` + SELECT id, name, user_id, version, created_at + FROM projects WHERE name = $1 + ` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + var p Project + err := pm.DB.QueryRowContext(ctx, query, name).Scan( + &p.ID, + &p.Name, + &p.UserID, + &p.Version, + &p.CreatedAt, + ) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrRecordNotFound + default: + return nil, fmt.Errorf("projects.getByName: %w", err) + } + } + return &p, nil +} + +func (pm *ProjectModel) GetAllForUser( + id int64, filters Filters, +) ([]Project, Metadata, error) { + totalRecords := 0 + md := Metadata{} + projects := make([]Project, 0, filters.limit()) + + query := fmt.Sprintf(` + SELECT count(*) OVER(), id, name, user_id, version, created_at + FROM projects + WHERE (user_id = $1 OR ($1 = 0 AND user_id IS NULL)) + ORDER BY %s %s, id ASC + LIMIT $2 OFFSET $3 + `, filters.sortColumn(), filters.sortDirection()) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + args := []any{id, filters.limit(), filters.offset()} + + rows, err := pm.DB.QueryContext(ctx, query, args...) + if err != nil { + return projects, md, fmt.Errorf("projects.getAllForUser: %w", err) + } + defer rows.Close() + + for rows.Next() { + var p Project + err := rows.Scan( + &totalRecords, + &p.ID, + &p.Name, + &p.UserID, + &p.Version, + &p.CreatedAt, + ) + if err != nil { + return projects, md, fmt.Errorf("projects.getAllForUser: %w", err) + } + projects = append(projects, p) + } + + if err := rows.Err(); err != nil { + return projects, md, fmt.Errorf("projects.getAllForUser: %w", err) + } + + md = calculateMetadata(totalRecords, filters.Page, filters.PageSize) + + return projects, md, nil +} + +func (pm *ProjectModel) Update(p *Project) error { + query := ` + UPDATE projects + SET name = $1, user_id = $2, version = version + 1 + WHERE id = $3 AND version = $4 + RETURNING version + ` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := pm.DB.QueryRowContext(ctx, query, p.Name, p.UserID, p.ID, p.Version).Scan(&p.Version) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return ErrEditConflict + default: + return fmt.Errorf("projects.update: %w", err) + } + } + return nil +} + +func (pm *ProjectModel) Delete(id int64) error { + query := ` + DELETE FROM projects + WHERE id = $1 + ` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + result, err := pm.DB.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("projects.delete: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("projects.delete: %w", err) + } + + if rows == 0 { + return ErrRecordNotFound + } + + return nil +} diff --git a/internal/data/users.go b/internal/data/users.go index 2ef0736..0806cf8 100644 --- a/internal/data/users.go +++ b/internal/data/users.go @@ -10,20 +10,9 @@ import ( vd "github.com/theolujay/appa/internal/validator" - "github.com/lib/pq" "golang.org/x/crypto/bcrypt" ) -const ( - pqUniqueViolation = "23505" - pqUsersEmailKey = "users_email_key" -) - -var ( - ErrDuplicateEmail = errors.New("duplicate email") - // ErrInvalidCredentials = errors.New("invalid credentials") -) - var AnonymousUser = &User{} func (u *User) IsAnonymous() bool { @@ -157,12 +146,9 @@ func (m UserModel) Insert(user *User) error { err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Version) if err != nil { - var pqErr *pq.Error switch { - case errors.As(err, &pqErr): - if pqErr.Code == pqUniqueViolation && pqErr.Constraint == pqUsersEmailKey { - return ErrDuplicateEmail - } + case isUniqueViolation(err, pqUsersEmailKey): + return ErrDuplicateEmail } return fmt.Errorf("users.insert: %w", err) } @@ -232,12 +218,9 @@ func (m UserModel) Update(user *User) error { err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.Version) if err != nil { - var pqErr *pq.Error switch { - case errors.As(err, &pqErr): - if pqErr.Code == pqUniqueViolation && pqErr.Constraint == pqUsersEmailKey { - return ErrDuplicateEmail - } + case isUniqueViolation(err, pqUsersEmailKey): + return ErrDuplicateEmail case errors.Is(err, sql.ErrNoRows): return ErrEditConflict default: diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 8eab3fe..ddeeb18 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -14,8 +14,9 @@ const ( ) type LogMessage struct { - ID int64 `json:"id"` - Line string `json:"line"` + ID int64 `json:"id"` + Line string `json:"line"` + Phase string `json:"phase,omitempty"` } type StatusUpdate struct { @@ -132,12 +133,16 @@ func (h *Hub) Run() { // all subscribed clients. This call enqueues the message on the // internal broadcast channel and returns immediately (subject to // channel buffering/backpressure). -func (h *Hub) PublishLog(deploymentID int64, log LogMessage) { +func (h *Hub) PublishLog(deploymentID int64, id int64, phase, line string) { h.broadcast <- InternalMessage{ DeploymentID: deploymentID, Event: Event{ Type: MessageTypeLog, - Log: log, + Log: LogMessage{ + ID: id, + Line: line, + Phase: phase, + }, }, } } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index b55ba6b..b5adbb8 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -82,7 +82,7 @@ func (p *Pipeline) logLine(id int64, phase, msg string) (int64, error) { if err != nil { return 0, fmt.Errorf("append log: %w", err) } - p.hub.PublishLog(id, hub.LogMessage{ID: logID, Line: msg}) + p.hub.PublishLog(id, logID, phase, msg) return logID, nil } @@ -224,9 +224,9 @@ func (p *Pipeline) Run(d *data.Deployment) { p.publishStatus(pc) } -// Cancel stops a deployment by either cancelling the active context +// Stop stops a deployment by either cancelling the active context // or stopping the associated container if it's already running. -func (p *Pipeline) Cancel(ID int64) error { +func (p *Pipeline) Stop(ID int64) error { p.mu.Lock() cancel, ok := p.activeTasks[ID] if ok { @@ -238,7 +238,7 @@ func (p *Pipeline) Cancel(ID int64) error { defer cancel() dc := pipelineCtx{ ctx: ctx, - status: canceled, + status: stopped, ID: ID, update: &data.DeploymentUpdate{}, } @@ -249,3 +249,56 @@ func (p *Pipeline) Cancel(ID int64) error { return dc.err } + +// Restart restarts a deployment's container and routing, given it was successfully built previously. +func (p *Pipeline) Restart(ID int64) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + pc := pipelineCtx{ + ctx: ctx, + status: deploying, + ID: ID, + update: &data.DeploymentUpdate{}, + } + + stopPC := pipelineCtx{ + ctx: ctx, + status: stopped, + ID: ID, + update: &data.DeploymentUpdate{}, + } + p.stopContainer(&stopPC) + if stopPC.err != nil { + p.publishStatus(stopPC) + return stopPC.err + } + + p.publishStatus(pc) + + addr, err := p.restartContainer(ctx, ID) + if err != nil { + pc.err = err + pc.status = failed + p.publishStatus(pc) + return err + } + + err = p.router.addRoute(ID, addr) + if err != nil { + pc.err = err + pc.status = failed + p.publishStatus(pc) + return err + } + + url := fmt.Sprintf("http://%d.localhost", ID) + status := running + pc.status = running + pc.update.URL = &url + pc.update.Status = &status + pc.update.Address = &addr + + p.publishStatus(pc) + return nil +} diff --git a/internal/pipeline/router.go b/internal/pipeline/router.go index 0e26965..8e64a08 100644 --- a/internal/pipeline/router.go +++ b/internal/pipeline/router.go @@ -155,7 +155,7 @@ func (r *Router) RestoreRoutes(dm data.DeploymentModeler) error { } // A user ID of 0 is treated as a wildcard // and fetches deployments for all users. - deployments, metadata, err := dm.GetAllForUser(0, running, filters) + deployments, metadata, err := dm.GetAllForUser(0, running, 0, filters) if err != nil { return fmt.Errorf("failed to list %d deployments for sync: %w", metadata.TotalRecords, err) } diff --git a/internal/pipeline/runner.go b/internal/pipeline/runner.go index db99727..dbd86e1 100644 --- a/internal/pipeline/runner.go +++ b/internal/pipeline/runner.go @@ -30,11 +30,6 @@ func (p *Pipeline) startContainer(ctx context.Context, id int64) (string, error) p.mu.Lock() defer p.mu.Unlock() - hPort, err := getPort() - if err != nil { - return "", fmt.Errorf("%w: %w", errDeployFailed, err) - } - res, err := p.dockerClient.ImageInspect(ctx, *d.ImageTag) if err != nil { return "", fmt.Errorf("%w: %w", errDeployFailed, err) @@ -114,7 +109,7 @@ func (p *Pipeline) startContainer(ctx context.Context, id int64) (string, error) } if !healthy { - return "", fmt.Errorf("%w: container did not respond on port %d: %w", errDeployFailed, hPort, errContainerNotReady) + return "", fmt.Errorf("%w: container did not become ready on %s: %w", errDeployFailed, addr, errContainerNotReady) } go func() { @@ -190,15 +185,14 @@ func (p *Pipeline) stopContainer(dc *pipelineCtx) { } } -// getPort finds and returns an available TCP port by binding to port 0 on localhost. -func getPort() (int, error) { - // the port number is automatically chosen with 0 as port in address parameter - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return 0, fmt.Errorf("listen on free port: %w", err) +// restartContainer restarts an already built and created container, sets up log streaming, and waits for it to be healthy. +func (p *Pipeline) restartContainer(ctx context.Context, id int64) (string, error) { + cName := fmt.Sprintf("appa-%d", id) + _, err := p.dockerClient.ContainerRemove(ctx, cName, client.ContainerRemoveOptions{Force: true}) + if err != nil && !cerrdefs.IsNotFound(err) { + return "", fmt.Errorf("%w: remove old container: %w", errDeployFailed, err) } - defer ln.Close() - return ln.Addr().(*net.TCPAddr).Port, nil + return p.startContainer(ctx, id) } // caddyLogFilter removes Caddy HTTP access logs from an input stream. diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go index 5ff2073..0ef954c 100644 --- a/internal/vcs/vcs.go +++ b/internal/vcs/vcs.go @@ -6,6 +6,7 @@ package vcs import ( "runtime/debug" + "strings" ) func Version() string { @@ -15,3 +16,10 @@ func Version() string { } return "" } + +// DockerTag returns a Docker-safe version tag by replacing +// characters invalid in image references (e.g. '+') with hyphens. +func DockerTag() string { + v := Version() + return strings.ReplaceAll(v, "+", "-") +} diff --git a/migrations/000006_create_projects_table.down.sql b/migrations/000006_create_projects_table.down.sql new file mode 100644 index 0000000..f17c3a8 --- /dev/null +++ b/migrations/000006_create_projects_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS projects; diff --git a/migrations/000006_create_projects_table.up.sql b/migrations/000006_create_projects_table.up.sql new file mode 100644 index 0000000..594f552 --- /dev/null +++ b/migrations/000006_create_projects_table.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS projects ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_projects_name ON projects(name); diff --git a/migrations/000007_add_project_id_to_deployments.down.sql b/migrations/000007_add_project_id_to_deployments.down.sql new file mode 100644 index 0000000..311358e --- /dev/null +++ b/migrations/000007_add_project_id_to_deployments.down.sql @@ -0,0 +1,3 @@ + +DROP INDEX IF EXISTS idx_deployments_project_id; +ALTER TABLE deployments DROP COLUMN IF EXISTS project_id; diff --git a/migrations/000007_add_project_id_to_deployments.up.sql b/migrations/000007_add_project_id_to_deployments.up.sql new file mode 100644 index 0000000..89b1315 --- /dev/null +++ b/migrations/000007_add_project_id_to_deployments.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE deployments ADD COLUMN project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL; +CREATE INDEX idx_deployments_project_id ON deployments(project_id); \ No newline at end of file