cli/internal/cmd/php/deploy.go
Snider 3365bfd5ba
feat(mcp): add workspace root validation to prevent path traversal (#100)
* feat(mcp): add workspace root validation to prevent path traversal

- Add workspaceRoot field to Service for restricting file operations
- Add WithWorkspaceRoot() option for configuring the workspace directory
- Add validatePath() helper to check paths are within workspace
- Apply validation to all file operation handlers
- Default to current working directory for security
- Add comprehensive tests for path validation

Closes #82

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: move CLI commands from pkg/ to internal/cmd/

- Move 18 CLI command packages to internal/cmd/ (not externally importable)
- Keep 16 library packages in pkg/ (externally importable)
- Update all import paths throughout codebase
- Cleaner separation between CLI logic and reusable libraries

CLI commands moved: ai, ci, dev, docs, doctor, gitcmd, go, monitor,
php, pkgcmd, qa, sdk, security, setup, test, updater, vm, workspace

Libraries remaining: agentic, build, cache, cli, container, devops,
errors, framework, git, i18n, io, log, mcp, process, release, repos

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(mcp): use pkg/io Medium for sandboxed file operations

Replace manual path validation with pkg/io.Medium for all file operations.
This delegates security (path traversal, symlink bypass) to the sandboxed
local.Medium implementation.

Changes:
- Add io.NewSandboxed() for creating sandboxed Medium instances
- Refactor MCP Service to use io.Medium instead of direct os.* calls
- Remove validatePath and resolvePathWithSymlinks functions
- Update tests to verify Medium-based behaviour

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: correct import path and workflow references

- Fix pkg/io/io.go import from core-gui to core
- Update CI workflows to use internal/cmd/updater path

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address CodeRabbit review issues for path validation

- pkg/io/local: add symlink resolution and boundary-aware containment
  - Reject absolute paths in sandboxed Medium
  - Use filepath.EvalSymlinks to prevent symlink bypass attacks
  - Fix prefix check to prevent /tmp/root matching /tmp/root2

- pkg/mcp: fix resolvePath to validate and return errors
  - Changed resolvePath from (string) to (string, error)
  - Update deleteFile, renameFile, listDirectory, fileExists to handle errors
  - Changed New() to return (*Service, error) instead of *Service
  - Properly propagate option errors instead of silently discarding

- pkg/io: wrap errors with E() helper for consistent context
  - Copy() and MockMedium.Read() now use coreerr.E()

- tests: rename to use _Good/_Bad/_Ugly suffixes per coding guidelines
  - Fix hardcoded /tmp in TestPath to use t.TempDir()
  - Add TestResolvePath_Bad_SymlinkTraversal test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix gofmt formatting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix gofmt formatting across all files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:59:34 +00:00

407 lines
10 KiB
Go

package php
import (
"context"
"time"
"github.com/host-uk/core/pkg/cli"
)
// Environment represents a deployment environment.
type Environment string
const (
// EnvProduction is the production environment.
EnvProduction Environment = "production"
// EnvStaging is the staging environment.
EnvStaging Environment = "staging"
)
// DeployOptions configures a deployment.
type DeployOptions struct {
// Dir is the project directory containing .env config.
Dir string
// Environment is the target environment (production or staging).
Environment Environment
// Force triggers a deployment even if no changes are detected.
Force bool
// Wait blocks until deployment completes.
Wait bool
// WaitTimeout is the maximum time to wait for deployment.
// Defaults to 10 minutes.
WaitTimeout time.Duration
// PollInterval is how often to check deployment status when waiting.
// Defaults to 5 seconds.
PollInterval time.Duration
}
// StatusOptions configures a status check.
type StatusOptions struct {
// Dir is the project directory containing .env config.
Dir string
// Environment is the target environment (production or staging).
Environment Environment
// DeploymentID is a specific deployment to check.
// If empty, returns the latest deployment.
DeploymentID string
}
// RollbackOptions configures a rollback.
type RollbackOptions struct {
// Dir is the project directory containing .env config.
Dir string
// Environment is the target environment (production or staging).
Environment Environment
// DeploymentID is the deployment to rollback to.
// If empty, rolls back to the previous successful deployment.
DeploymentID string
// Wait blocks until rollback completes.
Wait bool
// WaitTimeout is the maximum time to wait for rollback.
WaitTimeout time.Duration
}
// DeploymentStatus represents the status of a deployment.
type DeploymentStatus struct {
// ID is the deployment identifier.
ID string
// Status is the current deployment status.
// Values: queued, building, deploying, finished, failed, cancelled
Status string
// URL is the deployed application URL.
URL string
// Commit is the git commit SHA.
Commit string
// CommitMessage is the git commit message.
CommitMessage string
// Branch is the git branch.
Branch string
// StartedAt is when the deployment started.
StartedAt time.Time
// CompletedAt is when the deployment completed.
CompletedAt time.Time
// Log contains deployment logs.
Log string
}
// Deploy triggers a deployment to Coolify.
func Deploy(ctx context.Context, opts DeployOptions) (*DeploymentStatus, error) {
if opts.Dir == "" {
opts.Dir = "."
}
if opts.Environment == "" {
opts.Environment = EnvProduction
}
if opts.WaitTimeout == 0 {
opts.WaitTimeout = 10 * time.Minute
}
if opts.PollInterval == 0 {
opts.PollInterval = 5 * time.Second
}
// Load config
config, err := LoadCoolifyConfig(opts.Dir)
if err != nil {
return nil, cli.WrapVerb(err, "load", "Coolify config")
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, opts.Environment)
if appID == "" {
return nil, cli.Err("no app ID configured for %s environment", opts.Environment)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
// Trigger deployment
deployment, err := client.TriggerDeploy(ctx, appID, opts.Force)
if err != nil {
return nil, cli.WrapVerb(err, "trigger", "deployment")
}
status := convertDeployment(deployment)
// Wait for completion if requested
if opts.Wait && deployment.ID != "" {
status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, opts.PollInterval)
if err != nil {
return status, err
}
}
// Get app info for URL
app, err := client.GetApp(ctx, appID)
if err == nil && app.FQDN != "" {
status.URL = app.FQDN
}
return status, nil
}
// DeployStatus retrieves the status of a deployment.
func DeployStatus(ctx context.Context, opts StatusOptions) (*DeploymentStatus, error) {
if opts.Dir == "" {
opts.Dir = "."
}
if opts.Environment == "" {
opts.Environment = EnvProduction
}
// Load config
config, err := LoadCoolifyConfig(opts.Dir)
if err != nil {
return nil, cli.WrapVerb(err, "load", "Coolify config")
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, opts.Environment)
if appID == "" {
return nil, cli.Err("no app ID configured for %s environment", opts.Environment)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
var deployment *CoolifyDeployment
if opts.DeploymentID != "" {
// Get specific deployment
deployment, err = client.GetDeployment(ctx, appID, opts.DeploymentID)
if err != nil {
return nil, cli.WrapVerb(err, "get", "deployment")
}
} else {
// Get latest deployment
deployments, err := client.ListDeployments(ctx, appID, 1)
if err != nil {
return nil, cli.WrapVerb(err, "list", "deployments")
}
if len(deployments) == 0 {
return nil, cli.Err("no deployments found")
}
deployment = &deployments[0]
}
status := convertDeployment(deployment)
// Get app info for URL
app, err := client.GetApp(ctx, appID)
if err == nil && app.FQDN != "" {
status.URL = app.FQDN
}
return status, nil
}
// Rollback triggers a rollback to a previous deployment.
func Rollback(ctx context.Context, opts RollbackOptions) (*DeploymentStatus, error) {
if opts.Dir == "" {
opts.Dir = "."
}
if opts.Environment == "" {
opts.Environment = EnvProduction
}
if opts.WaitTimeout == 0 {
opts.WaitTimeout = 10 * time.Minute
}
// Load config
config, err := LoadCoolifyConfig(opts.Dir)
if err != nil {
return nil, cli.WrapVerb(err, "load", "Coolify config")
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, opts.Environment)
if appID == "" {
return nil, cli.Err("no app ID configured for %s environment", opts.Environment)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
// Find deployment to rollback to
deploymentID := opts.DeploymentID
if deploymentID == "" {
// Find previous successful deployment
deployments, err := client.ListDeployments(ctx, appID, 10)
if err != nil {
return nil, cli.WrapVerb(err, "list", "deployments")
}
// Skip the first (current) deployment, find the last successful one
for i, d := range deployments {
if i == 0 {
continue // Skip current deployment
}
if d.Status == "finished" || d.Status == "success" {
deploymentID = d.ID
break
}
}
if deploymentID == "" {
return nil, cli.Err("no previous successful deployment found to rollback to")
}
}
// Trigger rollback
deployment, err := client.Rollback(ctx, appID, deploymentID)
if err != nil {
return nil, cli.WrapVerb(err, "trigger", "rollback")
}
status := convertDeployment(deployment)
// Wait for completion if requested
if opts.Wait && deployment.ID != "" {
status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, 5*time.Second)
if err != nil {
return status, err
}
}
return status, nil
}
// ListDeployments retrieves recent deployments.
func ListDeployments(ctx context.Context, dir string, env Environment, limit int) ([]DeploymentStatus, error) {
if dir == "" {
dir = "."
}
if env == "" {
env = EnvProduction
}
if limit == 0 {
limit = 10
}
// Load config
config, err := LoadCoolifyConfig(dir)
if err != nil {
return nil, cli.WrapVerb(err, "load", "Coolify config")
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, env)
if appID == "" {
return nil, cli.Err("no app ID configured for %s environment", env)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
deployments, err := client.ListDeployments(ctx, appID, limit)
if err != nil {
return nil, cli.WrapVerb(err, "list", "deployments")
}
result := make([]DeploymentStatus, len(deployments))
for i, d := range deployments {
result[i] = *convertDeployment(&d)
}
return result, nil
}
// getAppIDForEnvironment returns the app ID for the given environment.
func getAppIDForEnvironment(config *CoolifyConfig, env Environment) string {
switch env {
case EnvStaging:
if config.StagingAppID != "" {
return config.StagingAppID
}
return config.AppID // Fallback to production
default:
return config.AppID
}
}
// convertDeployment converts a CoolifyDeployment to DeploymentStatus.
func convertDeployment(d *CoolifyDeployment) *DeploymentStatus {
return &DeploymentStatus{
ID: d.ID,
Status: d.Status,
URL: d.DeployedURL,
Commit: d.CommitSHA,
CommitMessage: d.CommitMsg,
Branch: d.Branch,
StartedAt: d.CreatedAt,
CompletedAt: d.FinishedAt,
Log: d.Log,
}
}
// waitForDeployment polls for deployment completion.
func waitForDeployment(ctx context.Context, client *CoolifyClient, appID, deploymentID string, timeout, interval time.Duration) (*DeploymentStatus, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
deployment, err := client.GetDeployment(ctx, appID, deploymentID)
if err != nil {
return nil, cli.WrapVerb(err, "get", "deployment status")
}
status := convertDeployment(deployment)
// Check if deployment is complete
switch deployment.Status {
case "finished", "success":
return status, nil
case "failed", "error":
return status, cli.Err("deployment failed: %s", deployment.Status)
case "cancelled":
return status, cli.Err("deployment was cancelled")
}
// Still in progress, wait and retry
select {
case <-ctx.Done():
return status, ctx.Err()
case <-time.After(interval):
}
}
return nil, cli.Err("deployment timed out after %v", timeout)
}
// IsDeploymentComplete returns true if the status indicates completion.
func IsDeploymentComplete(status string) bool {
switch status {
case "finished", "success", "failed", "error", "cancelled":
return true
default:
return false
}
}
// IsDeploymentSuccessful returns true if the status indicates success.
func IsDeploymentSuccessful(status string) bool {
return status == "finished" || status == "success"
}