go/pkg/php/container.go

450 lines
11 KiB
Go
Raw Normal View History

package php
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/cli"
)
// DockerBuildOptions configures Docker image building for PHP projects.
type DockerBuildOptions struct {
// ProjectDir is the path to the PHP/Laravel project.
ProjectDir string
// ImageName is the name for the Docker image.
ImageName string
// Tag is the image tag (default: "latest").
Tag string
// Platform specifies the target platform (e.g., "linux/amd64", "linux/arm64").
Platform string
// Dockerfile is the path to a custom Dockerfile.
// If empty, one will be auto-generated for FrankenPHP.
Dockerfile string
// NoBuildCache disables Docker build cache.
NoBuildCache bool
// BuildArgs are additional build arguments.
BuildArgs map[string]string
// Output is the writer for build output (default: os.Stdout).
Output io.Writer
}
// LinuxKitBuildOptions configures LinuxKit image building for PHP projects.
type LinuxKitBuildOptions struct {
// ProjectDir is the path to the PHP/Laravel project.
ProjectDir string
// OutputPath is the path for the output image.
OutputPath string
// Format is the output format: "iso", "qcow2", "raw", "vmdk".
Format string
// Template is the LinuxKit template name (default: "server-php").
Template string
// Variables are template variables to apply.
Variables map[string]string
// Output is the writer for build output (default: os.Stdout).
Output io.Writer
}
// ServeOptions configures running a production PHP container.
type ServeOptions struct {
// ImageName is the Docker image to run.
ImageName string
// Tag is the image tag (default: "latest").
Tag string
// ContainerName is the name for the container.
ContainerName string
// Port is the host port to bind (default: 80).
Port int
// HTTPSPort is the host HTTPS port to bind (default: 443).
HTTPSPort int
// Detach runs the container in detached mode.
Detach bool
// EnvFile is the path to an environment file.
EnvFile string
// Volumes maps host paths to container paths.
Volumes map[string]string
// Output is the writer for output (default: os.Stdout).
Output io.Writer
}
// BuildDocker builds a Docker image for the PHP project.
func BuildDocker(ctx context.Context, opts DockerBuildOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
opts.ProjectDir = cwd
}
// Validate project directory
if !IsPHPProject(opts.ProjectDir) {
return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir)
}
// Set defaults
if opts.ImageName == "" {
opts.ImageName = filepath.Base(opts.ProjectDir)
}
if opts.Tag == "" {
opts.Tag = "latest"
}
if opts.Output == nil {
opts.Output = os.Stdout
}
// Determine Dockerfile path
dockerfilePath := opts.Dockerfile
var tempDockerfile string
if dockerfilePath == "" {
// Generate Dockerfile
content, err := GenerateDockerfile(opts.ProjectDir)
if err != nil {
return cli.WrapVerb(err, "generate", "Dockerfile")
}
// Write to temporary file
tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated")
if err := os.WriteFile(tempDockerfile, []byte(content), 0644); err != nil {
return cli.WrapVerb(err, "write", "Dockerfile")
}
feat: git command, build improvements, and go fmt git-aware (#74) * feat(go): make go fmt git-aware by default - By default, only check changed Go files (modified, staged, untracked) - Add --all flag to check all files (previous behaviour) - Reduces noise when running fmt on large codebases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(build): minimal output by default, add missing i18n - Default output now shows single line: "Success Built N artifacts (dir)" - Add --verbose/-v flag to show full detailed output - Add all missing i18n translations for build commands - Errors still show failure reason in minimal mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add root-level `core git` command - Create pkg/gitcmd with git workflow commands as root menu - Export command builders from pkg/dev (AddCommitCommand, etc.) - Commands available under both `core git` and `core dev` for compatibility - Git commands: health, commit, push, pull, work, sync, apply - GitHub orchestration stays in dev: issues, reviews, ci, impact Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(qa): add docblock coverage checking Implement docblock/docstring coverage analysis for Go code: - New `core qa docblock` command to check coverage - Shows compact file:line list when under threshold - Integrate with `core go qa` as a default check - Add --docblock-threshold flag (default 80%) The checker uses Go AST parsing to find exported symbols (functions, types, consts, vars) without documentation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - Fix doc comment: "status" → "health" in gitcmd package - Implement --check flag for `core go fmt` (exits non-zero if files need formatting) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add docstrings for 100% coverage Add documentation comments to all exported symbols: - pkg/build: ProjectType constants - pkg/cli: LogLevel, RenderStyle, TableStyle - pkg/framework: ServiceFor, MustServiceFor, Core.Core - pkg/git: GitError.Error, GitError.Unwrap - pkg/i18n: Handler Match/Handle methods - pkg/log: Level constants - pkg/mcp: Tool input/output types - pkg/php: Service constants, QA types, service methods - pkg/process: ServiceError.Error - pkg/repos: RepoType constants - pkg/setup: ChangeType, ChangeCategory constants - pkg/workspace: AddWorkspaceCommands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: standardize line endings to LF Add .gitattributes to enforce LF line endings for all text files. Normalize all existing files to use Unix-style line endings. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - cmd_format.go: validate --check/--fix mutual exclusivity, capture stderr - cmd_docblock.go: return error instead of os.Exit(1) for proper error handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback (round 2) - linuxkit.go: propagate state update errors, handle cmd.Wait() errors in waitForExit - mcp.go: guard against empty old_string in editDiff to prevent runaway edits - cmd_docblock.go: log parse errors instead of silently skipping Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 10:48:44 +00:00
defer func() { _ = os.Remove(tempDockerfile) }()
dockerfilePath = tempDockerfile
}
// Build Docker image
imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag)
args := []string{"build", "-t", imageRef, "-f", dockerfilePath}
if opts.Platform != "" {
args = append(args, "--platform", opts.Platform)
}
if opts.NoBuildCache {
args = append(args, "--no-cache")
}
for key, value := range opts.BuildArgs {
args = append(args, "--build-arg", cli.Sprintf("%s=%s", key, value))
}
args = append(args, opts.ProjectDir)
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Dir = opts.ProjectDir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
if err := cmd.Run(); err != nil {
return cli.Wrap(err, "docker build failed")
}
return nil
}
// BuildLinuxKit builds a LinuxKit image for the PHP project.
func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
opts.ProjectDir = cwd
}
// Validate project directory
if !IsPHPProject(opts.ProjectDir) {
return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir)
}
// Set defaults
if opts.Template == "" {
opts.Template = "server-php"
}
if opts.Format == "" {
opts.Format = "qcow2"
}
if opts.OutputPath == "" {
opts.OutputPath = filepath.Join(opts.ProjectDir, "dist", filepath.Base(opts.ProjectDir))
}
if opts.Output == nil {
opts.Output = os.Stdout
}
// Ensure output directory exists
outputDir := filepath.Dir(opts.OutputPath)
if err := os.MkdirAll(outputDir, 0755); err != nil {
return cli.WrapVerb(err, "create", "output directory")
}
// Find linuxkit binary
linuxkitPath, err := lookupLinuxKit()
if err != nil {
return err
}
// Get template content
templateContent, err := getLinuxKitTemplate(opts.Template)
if err != nil {
return cli.WrapVerb(err, "get", "template")
}
// Apply variables
if opts.Variables == nil {
opts.Variables = make(map[string]string)
}
// Add project-specific variables
opts.Variables["PROJECT_DIR"] = opts.ProjectDir
opts.Variables["PROJECT_NAME"] = filepath.Base(opts.ProjectDir)
content, err := applyTemplateVariables(templateContent, opts.Variables)
if err != nil {
return cli.WrapVerb(err, "apply", "template variables")
}
// Write template to temp file
tempYAML := filepath.Join(opts.ProjectDir, ".core-linuxkit.yml")
if err := os.WriteFile(tempYAML, []byte(content), 0644); err != nil {
return cli.WrapVerb(err, "write", "template")
}
feat: git command, build improvements, and go fmt git-aware (#74) * feat(go): make go fmt git-aware by default - By default, only check changed Go files (modified, staged, untracked) - Add --all flag to check all files (previous behaviour) - Reduces noise when running fmt on large codebases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(build): minimal output by default, add missing i18n - Default output now shows single line: "Success Built N artifacts (dir)" - Add --verbose/-v flag to show full detailed output - Add all missing i18n translations for build commands - Errors still show failure reason in minimal mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add root-level `core git` command - Create pkg/gitcmd with git workflow commands as root menu - Export command builders from pkg/dev (AddCommitCommand, etc.) - Commands available under both `core git` and `core dev` for compatibility - Git commands: health, commit, push, pull, work, sync, apply - GitHub orchestration stays in dev: issues, reviews, ci, impact Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(qa): add docblock coverage checking Implement docblock/docstring coverage analysis for Go code: - New `core qa docblock` command to check coverage - Shows compact file:line list when under threshold - Integrate with `core go qa` as a default check - Add --docblock-threshold flag (default 80%) The checker uses Go AST parsing to find exported symbols (functions, types, consts, vars) without documentation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - Fix doc comment: "status" → "health" in gitcmd package - Implement --check flag for `core go fmt` (exits non-zero if files need formatting) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add docstrings for 100% coverage Add documentation comments to all exported symbols: - pkg/build: ProjectType constants - pkg/cli: LogLevel, RenderStyle, TableStyle - pkg/framework: ServiceFor, MustServiceFor, Core.Core - pkg/git: GitError.Error, GitError.Unwrap - pkg/i18n: Handler Match/Handle methods - pkg/log: Level constants - pkg/mcp: Tool input/output types - pkg/php: Service constants, QA types, service methods - pkg/process: ServiceError.Error - pkg/repos: RepoType constants - pkg/setup: ChangeType, ChangeCategory constants - pkg/workspace: AddWorkspaceCommands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: standardize line endings to LF Add .gitattributes to enforce LF line endings for all text files. Normalize all existing files to use Unix-style line endings. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - cmd_format.go: validate --check/--fix mutual exclusivity, capture stderr - cmd_docblock.go: return error instead of os.Exit(1) for proper error handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback (round 2) - linuxkit.go: propagate state update errors, handle cmd.Wait() errors in waitForExit - mcp.go: guard against empty old_string in editDiff to prevent runaway edits - cmd_docblock.go: log parse errors instead of silently skipping Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 10:48:44 +00:00
defer func() { _ = os.Remove(tempYAML) }()
// Build LinuxKit image
args := []string{
"build",
"--format", opts.Format,
"--name", opts.OutputPath,
tempYAML,
}
cmd := exec.CommandContext(ctx, linuxkitPath, args...)
cmd.Dir = opts.ProjectDir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
if err := cmd.Run(); err != nil {
return cli.Wrap(err, "linuxkit build failed")
}
return nil
}
// ServeProduction runs a production PHP container.
func ServeProduction(ctx context.Context, opts ServeOptions) error {
if opts.ImageName == "" {
return cli.Err("image name is required")
}
// Set defaults
if opts.Tag == "" {
opts.Tag = "latest"
}
if opts.Port == 0 {
opts.Port = 80
}
if opts.HTTPSPort == 0 {
opts.HTTPSPort = 443
}
if opts.Output == nil {
opts.Output = os.Stdout
}
imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag)
args := []string{"run"}
if opts.Detach {
args = append(args, "-d")
} else {
args = append(args, "--rm")
}
if opts.ContainerName != "" {
args = append(args, "--name", opts.ContainerName)
}
// Port mappings
args = append(args, "-p", cli.Sprintf("%d:80", opts.Port))
args = append(args, "-p", cli.Sprintf("%d:443", opts.HTTPSPort))
// Environment file
if opts.EnvFile != "" {
args = append(args, "--env-file", opts.EnvFile)
}
// Volume mounts
for hostPath, containerPath := range opts.Volumes {
args = append(args, "-v", cli.Sprintf("%s:%s", hostPath, containerPath))
}
args = append(args, imageRef)
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
if opts.Detach {
output, err := cmd.Output()
if err != nil {
return cli.WrapVerb(err, "start", "container")
}
containerID := strings.TrimSpace(string(output))
cli.Print("Container started: %s\n", containerID[:12])
return nil
}
return cmd.Run()
}
// Shell opens a shell in a running container.
func Shell(ctx context.Context, containerID string) error {
if containerID == "" {
return cli.Err("container ID is required")
}
// Resolve partial container ID
fullID, err := resolveDockerContainerID(ctx, containerID)
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, "docker", "exec", "-it", fullID, "/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// IsPHPProject checks if the given directory is a PHP project.
func IsPHPProject(dir string) bool {
composerPath := filepath.Join(dir, "composer.json")
_, err := os.Stat(composerPath)
return err == nil
}
// commonLinuxKitPaths defines default search locations for linuxkit.
var commonLinuxKitPaths = []string{
"/usr/local/bin/linuxkit",
"/opt/homebrew/bin/linuxkit",
}
// lookupLinuxKit finds the linuxkit binary.
func lookupLinuxKit() (string, error) {
// Check PATH first
if path, err := exec.LookPath("linuxkit"); err == nil {
return path, nil
}
for _, p := range commonLinuxKitPaths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", cli.Err("linuxkit not found. Install with: brew install linuxkit (macOS) or see https://github.com/linuxkit/linuxkit")
}
// getLinuxKitTemplate retrieves a LinuxKit template by name.
func getLinuxKitTemplate(name string) (string, error) {
// Default server-php template for PHP projects
if name == "server-php" {
return defaultServerPHPTemplate, nil
}
// Try to load from container package templates
// This would integrate with github.com/host-uk/core/pkg/container
return "", cli.Err("template not found: %s", name)
}
// applyTemplateVariables applies variable substitution to template content.
func applyTemplateVariables(content string, vars map[string]string) (string, error) {
result := content
for key, value := range vars {
placeholder := "${" + key + "}"
result = strings.ReplaceAll(result, placeholder, value)
}
return result, nil
}
// resolveDockerContainerID resolves a partial container ID to a full ID.
func resolveDockerContainerID(ctx context.Context, partialID string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--no-trunc", "--format", "{{.ID}}")
output, err := cmd.Output()
if err != nil {
return "", cli.WrapVerb(err, "list", "containers")
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var matches []string
for _, line := range lines {
if strings.HasPrefix(line, partialID) {
matches = append(matches, line)
}
}
switch len(matches) {
case 0:
return "", cli.Err("no container found matching: %s", partialID)
case 1:
return matches[0], nil
default:
return "", cli.Err("multiple containers match '%s', be more specific", partialID)
}
}
// defaultServerPHPTemplate is the default LinuxKit template for PHP servers.
const defaultServerPHPTemplate = `# LinuxKit configuration for PHP/FrankenPHP server
kernel:
image: linuxkit/kernel:6.6.13
cmdline: "console=tty0 console=ttyS0"
init:
- linuxkit/init:v1.0.1
- linuxkit/runc:v1.0.1
- linuxkit/containerd:v1.0.1
onboot:
- name: sysctl
image: linuxkit/sysctl:v1.0.1
- name: dhcpcd
image: linuxkit/dhcpcd:v1.0.1
command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"]
services:
- name: getty
image: linuxkit/getty:v1.0.1
env:
- INSECURE=true
- name: sshd
image: linuxkit/sshd:v1.0.1
files:
- path: etc/ssh/authorized_keys
contents: |
${SSH_KEY:-}
`