Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 47 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,57 @@ and signed `.ghostable/` records. GitHub Actions workflow references under
`.env`. It replaces `.env` by default so stale deploy values do not survive
between runs. Use `--merge` when an existing file should be preserved.

Provider targets use `ghostable deploy <target> [environment] [options]`:

```sh
ghostable deploy laravel-forge production
ghostable deploy laravel-vapor production
ghostable deploy laravel-cloud production
```

Laravel Vapor deploys can split regular environment variables from values that
should be stored through Vapor Secrets:

```sh
ghostable var vapor-secret --env production --key APP_KEY --enabled
ghostable deploy vapor production --dry-run
ghostable deploy vapor production
ghostable deploy laravel-vapor production --dry-run
ghostable deploy laravel-vapor production
```

`ghostable deploy laravel-vapor` requires the `vapor` CLI on `PATH` unless
`--dry-run` is used. Regular variables are merged into Vapor's
`.env.<environment>` file and pushed with `vapor env:push`; variables marked as
Vapor Secrets are synced with Vapor Secrets instead.

Laravel Cloud deploys sync Ghostable values to Laravel Cloud environment
variables using the Laravel Cloud CLI:

```sh
ghostable deploy laravel-cloud production --dry-run
ghostable deploy laravel-cloud production --cloud-env production
```

`ghostable deploy laravel-cloud` requires the `cloud` CLI on `PATH` unless
`--dry-run` is used. It calls `cloud environment:variables <environment>` with
`--action=set` for each selected key, so existing Cloud variables with matching
keys are updated and missing keys are added. The Cloud environment defaults to
the Ghostable environment name; use `--cloud-env` when Laravel Cloud uses a
different environment ID or name.

Laravel Forge deploys sync Ghostable values to a Forge site's environment file
using the Laravel Forge CLI:

```sh
ghostable deploy laravel-forge production --dry-run --forge-site example.com
ghostable deploy laravel-forge production --forge-site example.com
```

`ghostable deploy vapor` requires the `vapor` CLI on `PATH` unless `--dry-run`
is used. Regular variables are merged into Vapor's `.env.<environment>` file and
pushed with `vapor env:push`; variables marked as Vapor Secrets are synced with
Vapor Secrets instead.
`ghostable deploy laravel-forge` requires the `forge` CLI on `PATH` unless
`--dry-run` is used. It pulls the remote site environment file with
`forge env:pull`, merges selected Ghostable values into a temporary file, then
pushes the result with `forge env:push`. Existing Forge variables with matching
keys are updated and missing keys are added. Pass `--forge-site` with the Forge
site name that should receive the variables.

Deploy systems can use a scoped automation credential instead of a local device
identity:
Expand All @@ -149,18 +187,13 @@ but before Laravel commands that read `.env`:
```sh
npm ci
export GHOSTABLE_CI_TOKEN="$(cat "$HOME/.ghostable-ci-token")"
npx --no-install ghostable deploy production
ghostable deploy laravel-forge production --forge-site example.com
npm run build
$FORGE_PHP artisan migrate --force
```

For Laravel Cloud, do not rely on `ghostable deploy` inside Cloud deploy
commands to persist a generated `.env` file. Laravel Cloud deploy commands run
on Cloud infrastructure just before a deployment goes live, but filesystem
changes made there are not persisted to the application. Until Ghostable has a
native Laravel Cloud environment sync command, use Ghostable as the source of
truth locally and copy/sync the values into Laravel Cloud's environment variable
settings.
For Laravel Cloud, run `ghostable deploy laravel-cloud` before starting a Cloud
deployment when changed variables should be available to the next deploy.

## Secret Scanning

Expand Down
4 changes: 3 additions & 1 deletion internal/app/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ func (r *Runner) runAgentCapabilities(args []string) error {
"validate --file <path> --json",
"review --base <ref> --env <env> --json",
"deploy <env> --dry-run --json",
"deploy vapor <env> --dry-run --json",
"deploy laravel-forge <env> --forge-site <site> --dry-run --json",
"deploy laravel-cloud <env> --dry-run --json",
"deploy laravel-vapor <env> --dry-run --json",
"var pull --env <env> --key <key> --json",
"var history --env <env> --key <key> --json",
"scan --json",
Expand Down
19 changes: 12 additions & 7 deletions internal/app/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ func (r *Runner) runDeploy(args []string) error {
}
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
switch args[0] {
case "vapor":
case "laravel-vapor":
return r.runDeployVapor(args[1:])
case "forge":
return fmt.Errorf("`ghostable deploy forge` is not implemented in the Go client yet")
case "cloud", "laravel-cloud":
return fmt.Errorf("`ghostable deploy cloud` is not implemented in the Go client yet")
case "laravel-forge":
return r.runDeployForge(args[1:])
case "laravel-cloud":
return r.runDeployCloud(args[1:])
}
}
return r.runDeployWrite(args)
Expand Down Expand Up @@ -60,9 +60,14 @@ func (r *Runner) runDeployWrite(args []string) error {
}

func (r *Runner) printDeployHelp() {
fmt.Fprintln(r.out, "Usage: ghostable deploy [environment] [options]")
fmt.Fprintln(r.out, "Usage: ghostable deploy [target] [environment] [options]")
fmt.Fprintln(r.out)
fmt.Fprintln(r.out, "Decrypt an environment into a local .env file for deploy scripts.")
fmt.Fprintln(r.out, "Decrypt an environment into a local .env file or sync it to a supported deployment provider.")
fmt.Fprintln(r.out)
fmt.Fprintln(r.out, warn("Targets:"))
fmt.Fprintln(r.out, " laravel-forge Sync Laravel Forge site environment variables")
fmt.Fprintln(r.out, " laravel-vapor Sync Laravel Vapor environment variables and Vapor Secrets")
fmt.Fprintln(r.out, " laravel-cloud Sync Laravel Cloud environment variables")
fmt.Fprintln(r.out)
fmt.Fprintln(r.out, warn("Options:"))
fmt.Fprintln(r.out, " --env <ENV> Environment name")
Expand Down
245 changes: 245 additions & 0 deletions internal/app/deploy_laravel_cloud.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package app

import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

"github.com/ghostable-dev/beta/internal/cli"
"github.com/ghostable-dev/beta/internal/store"
)

const cloudCommandTimeout = 2 * time.Minute

type cloudDeployPlan struct {
Target string `json:"target"`
Provider string `json:"provider"`
Environment string `json:"environment"`
CloudEnvironment string `json:"cloudEnvironment"`
DryRun bool `json:"dryRun"`
Synced bool `json:"synced"`
Variables []string `json:"variables"`
Device string `json:"device"`
Source string `json:"source,omitempty"`
values map[string]string `json:"-"`
}

func (r *Runner) runDeployCloud(args []string) error {
if len(args) > 0 && isHelpArg(args[0]) {
r.printDeployCloudHelp()
return nil
}

fs := newFlagSet("deploy laravel-cloud", r.errOut)
env := fs.String("env", "", "Ghostable environment name")
cloudEnv := fs.String("cloud-env", "", "Laravel Cloud environment ID or name")
var only cli.Strings
fs.Var(&only, "only", "Only include these keys")
dryRun := fs.Bool("dry-run", false, "Show what would sync without invoking Laravel Cloud")
jsonOut := fs.Bool("json", false, "Print Laravel Cloud deploy result as JSON")
positionals, err := cli.Parse(fs, args, cli.BoolFlags("dry-run", "json"))
if err != nil {
return err
}
if len(positionals) > 1 {
return fmt.Errorf("usage: ghostable deploy laravel-cloud [environment] [options]")
}
if *env == "" && len(positionals) == 1 {
*env = positionals[0]
}

repo, err := r.openRepo()
if err != nil {
return err
}
selected, err := r.selectEnvironment(repo, *env)
if err != nil {
return err
}

cloudEnvironment := strings.TrimSpace(*cloudEnv)
if cloudEnvironment == "" {
cloudEnvironment = selected
}
if cloudEnvironment == "" {
return fmt.Errorf("Laravel Cloud environment is required")
}

plan, err := buildCloudDeployPlan(repo, selected, cloudEnvironment, only)
if err != nil {
return err
}
plan.DryRun = *dryRun

if *dryRun {
if *jsonOut {
return printJSON(r.out, plan)
}
r.printCloudDeployPlan(plan)
return nil
}

cloudPath, err := resolveCloudBinary(repo.Root)
if err != nil {
return err
}
if err := syncCloudDeployPlan(plan, cloudPath); err != nil {
return err
}
plan.Synced = true

if *jsonOut {
return printJSON(r.out, plan)
}
r.printCloudDeploySuccess(plan)
return nil
}

func (r *Runner) printDeployCloudHelp() {
fmt.Fprintln(r.out, "Usage: ghostable deploy laravel-cloud [environment] [options]")
fmt.Fprintln(r.out)
fmt.Fprintln(r.out, "Sync decrypted values to Laravel Cloud environment variables using the Laravel Cloud CLI.")
fmt.Fprintln(r.out)
fmt.Fprintln(r.out, warn("Options:"))
fmt.Fprintln(r.out, " --env <ENV> Ghostable environment name")
fmt.Fprintln(r.out, " --cloud-env <ENV> Laravel Cloud environment ID or name (defaults to the Ghostable environment)")
fmt.Fprintln(r.out, " --only <KEYS> Only include these keys; may be repeated or comma-separated")
fmt.Fprintln(r.out, " --dry-run Show what would sync without invoking Laravel Cloud")
fmt.Fprintln(r.out, " --json Print Laravel Cloud deploy result as JSON")
}

func buildCloudDeployPlan(repo store.Repository, env string, cloudEnv string, only []string) (cloudDeployPlan, error) {
variables, err := repo.ReadVariables(env)
if err != nil {
return cloudDeployPlan{}, err
}

plan := cloudDeployPlan{
Target: "laravel-cloud",
Provider: "Laravel Cloud",
Environment: env,
CloudEnvironment: cloudEnv,
Device: deployIdentityDisplay(repo),
Source: strings.TrimSpace(deployIdentitySource(repo)),
values: map[string]string{},
}

onlyKeys := deployOnlyKeySet(only)
for _, key := range vaporVariableKeys(variables) {
if len(onlyKeys) > 0 && !onlyKeys[key] {
continue
}
variable := variables[key]
plan.values[key] = variable.Value
plan.Variables = append(plan.Variables, key)
}

return plan, nil
}

func deployOnlyKeySet(keys []string) map[string]bool {
result := map[string]bool{}
for _, key := range keys {
key = strings.TrimSpace(key)
if key != "" {
result[key] = true
}
}
return result
}

func syncCloudDeployPlan(plan cloudDeployPlan, cloudPath string) error {
for _, key := range plan.Variables {
if err := syncCloudEnvironmentVariable(cloudPath, plan.CloudEnvironment, key, plan.values[key]); err != nil {
return err
}
}
return nil
}

func syncCloudEnvironmentVariable(cloudPath string, cloudEnv string, key string, value string) error {
if strings.ContainsRune(value, 0) {
return fmt.Errorf("sync Laravel Cloud variable %s: value contains a NUL byte", key)
}

ctx, cancel := context.WithTimeout(context.Background(), cloudCommandTimeout)
defer cancel()

cmd := exec.CommandContext(
ctx,
cloudPath,
"environment:variables",
cloudEnv,
"--json",
"--no-interaction",
"--action=set",
"--key="+key,
"--value="+value,
)
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("sync Laravel Cloud variable %s: Cloud CLI timed out", key)
}
if err != nil {
detail := sanitizeCloudCommandOutput(string(output), value)
if detail == "" {
detail = err.Error()
}
return fmt.Errorf("sync Laravel Cloud variable %s: %s", key, detail)
}
return nil
}

func sanitizeCloudCommandOutput(output string, sensitiveValue string) string {
detail := strings.TrimSpace(output)
if detail == "" || sensitiveValue == "" {
return detail
}
return strings.ReplaceAll(detail, sensitiveValue, "[redacted]")
}

func resolveCloudBinary(projectRoot string) (string, error) {
path, err := exec.LookPath("cloud")
if err != nil {
return "", fmt.Errorf("Laravel Cloud CLI not found on PATH; install it with `composer global require laravel/cloud-cli` before running `ghostable deploy laravel-cloud`")
}
absolutePath, err := filepath.Abs(path)
if err != nil {
return "", err
}
if binaryInsideProject(projectRoot, absolutePath) {
return "", fmt.Errorf("refusing to run Laravel Cloud CLI from project path %s; put a trusted Cloud executable earlier on PATH outside this repository", absolutePath)
}
info, err := os.Stat(absolutePath)
if err != nil {
return "", err
}
if info.IsDir() {
return "", fmt.Errorf("Laravel Cloud CLI path %s is a directory", absolutePath)
}
return absolutePath, nil
}

func (r *Runner) printCloudDeployPlan(plan cloudDeployPlan) {
fmt.Fprintln(r.out, success("👻 Ghostable Laravel Cloud deploy plan."))
printCloudDeployDetails(r, plan)
}

func (r *Runner) printCloudDeploySuccess(plan cloudDeployPlan) {
fmt.Fprintln(r.out, success("👻 Ghostable Laravel Cloud deploy successful."))
printCloudDeployDetails(r, plan)
}

func printCloudDeployDetails(r *Runner, plan cloudDeployPlan) {
printDeployDetail(r.out, "Environment", plan.Environment)
printDeployDetail(r.out, "Cloud environment", plan.CloudEnvironment)
printDeployDetail(r.out, "Variables", deployVariableCount(len(plan.Variables)))
printDeployDetail(r.out, "Device", plan.Device)
if plan.Source != "" {
printDeployDetail(r.out, "Source", plan.Source)
}
}
Loading