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
143 changes: 143 additions & 0 deletions pkg/plugin/standard_app_plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package plugin

import (
"fmt"
"strings"

corecomponent "github.com/libops/sitectl/pkg/component"
coredevmode "github.com/libops/sitectl/pkg/services/devmode"
coretraefik "github.com/libops/sitectl/pkg/services/traefik"
)

// StandardComposeAppPluginOptions configures the common registration shape for
// compose-backed application plugins.
type StandardComposeAppPluginOptions struct {
PluginName string
DisplayName string
AppService string
Router string
DefaultPath string
ReadyMessage string
Discovery ComposeProjectDiscovery
CreateSpec CreateSpec
CreateOptions ComposeTemplateCreateOptions
IngressOptions coretraefik.IngressOptions
DevModeOptions coredevmode.Options
Healthcheck HealthcheckRunner
IngressRouteProvider IngressRouteProvider
ExtraComponents []corecomponent.ComposeServiceComponent
}

// RegisterStandardComposeAppPlugin registers discovery, template create,
// ingress, dev-mode, healthcheck, and route discovery for a standard
// compose-backed web application plugin.
func (s *SDK) RegisterStandardComposeAppPlugin(opts StandardComposeAppPluginOptions) error {
if s == nil {
return nil
}
pluginName := strings.TrimSpace(opts.PluginName)
if pluginName == "" {
pluginName = s.Metadata.Name
}
appService := strings.TrimSpace(opts.AppService)
if appService == "" {
appService = pluginName
}
displayName := strings.TrimSpace(opts.DisplayName)
if displayName == "" {
displayName = appService
}

discovery := opts.Discovery
if len(discovery.RequiredServices) == 0 {
discovery.RequiredServices = []string{appService}
}
if strings.TrimSpace(discovery.Reason) == "" && len(discovery.RequiredServices) > 0 {
discovery.Reason = discovery.RequiredServices[0] + " service"
}
s.SetComposeProjectDiscovery(discovery)

createOptions := opts.CreateOptions
if strings.TrimSpace(createOptions.DefaultPath) == "" {
createOptions.DefaultPath = opts.DefaultPath
}
if strings.TrimSpace(createOptions.DefaultPlugin) == "" {
createOptions.DefaultPlugin = pluginName
}
if strings.TrimSpace(createOptions.ReadyMessage) == "" {
createOptions.ReadyMessage = opts.ReadyMessage
}
createSpec := normalizeCreateSpec(opts.CreateSpec)
if strings.TrimSpace(createSpec.Name) == "" {
createSpec.Name = "default"
createSpec.Default = true
}
if strings.TrimSpace(createSpec.Plugin) == "" {
createSpec.Plugin = pluginName
}
s.RegisterComposeTemplateCreateRunner(createSpec, createOptions)

components, err := standardComposeAppComponents(appService, opts)
if err != nil {
return err
}
s.RegisterServiceComponents(ServiceComponentRegistryOptions{
DisplayName: displayName,
Components: components,
})

if opts.Healthcheck != nil {
s.RegisterHealthcheckRunner(opts.Healthcheck)
}
if opts.IngressRouteProvider != nil {
s.RegisterIngressRouteProvider(opts.IngressRouteProvider)
} else {
router := strings.TrimSpace(opts.Router)
if router == "" {
router = appService
}
s.RegisterIngressRouteProvider(StandardComposeWebIngressRoutesWithOptions(StandardComposeWebIngressOptions{
AppService: appService,
Router: router,
}))
}
return nil
}

func standardComposeAppComponents(appService string, opts StandardComposeAppPluginOptions) ([]corecomponent.ComposeServiceComponent, error) {
ingressOptions := opts.IngressOptions
if strings.TrimSpace(ingressOptions.AppService) == "" {
ingressOptions.AppService = appService
}
if strings.TrimSpace(ingressOptions.HTTPEntrypoint) == "" {
ingressOptions.HTTPEntrypoint = "web"
}
if strings.TrimSpace(ingressOptions.HTTPSEntrypoint) == "" {
ingressOptions.HTTPSEntrypoint = "websecure"
}
ingress, err := coretraefik.Ingress(ingressOptions)
if err != nil {
return nil, fmt.Errorf("build %s ingress component: %w", appService, err)
}

devModeOptions := opts.DevModeOptions
if strings.TrimSpace(devModeOptions.AppService) == "" {
devModeOptions.AppService = appService
}
devMode, err := coredevmode.Component(devModeOptions)
if err != nil {
return nil, fmt.Errorf("build %s dev-mode component: %w", appService, err)
}

components := []corecomponent.ComposeServiceComponent{ingress, devMode}
components = append(components, opts.ExtraComponents...)
return components, nil
}

// MustRegisterStandardComposeAppPlugin is like RegisterStandardComposeAppPlugin,
// but panics when component construction fails during plugin startup.
func (s *SDK) MustRegisterStandardComposeAppPlugin(opts StandardComposeAppPluginOptions) {
if err := s.RegisterStandardComposeAppPlugin(opts); err != nil {
panic(err)
}
}
85 changes: 85 additions & 0 deletions pkg/plugin/standard_app_plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package plugin

import (
"os"
"path/filepath"
"testing"

"github.com/libops/sitectl/pkg/config"
coredevmode "github.com/libops/sitectl/pkg/services/devmode"
coretraefik "github.com/libops/sitectl/pkg/services/traefik"
sitevalidate "github.com/libops/sitectl/pkg/validate"
"github.com/spf13/cobra"
)

type standardAppHealthcheckStub struct{}

func (standardAppHealthcheckStub) BindFlags(cmd *cobra.Command) {}

func (standardAppHealthcheckStub) Run(cmd *cobra.Command, ctx *config.Context) ([]sitevalidate.Result, error) {
return nil, nil
}

func TestRegisterStandardComposeAppPlugin(t *testing.T) {
sdk := NewSDK(Metadata{Name: "wp"})

err := sdk.RegisterStandardComposeAppPlugin(StandardComposeAppPluginOptions{
DisplayName: "WordPress",
AppService: "wordpress",
Router: "wordpress-web",
DefaultPath: "./wp",
ReadyMessage: "WordPress is ready",
CreateSpec: CreateSpec{
Name: "default",
Default: true,
},
IngressOptions: coretraefik.IngressOptions{
RouterHosts: map[string]string{"wordpress-web": "INGRESS_HOSTNAMES"},
},
DevModeOptions: coredevmode.Options{
Volumes: []string{"./wp-content:/var/www/html/wp-content:rw"},
},
Healthcheck: standardAppHealthcheckStub{},
})
if err != nil {
t.Fatalf("RegisterStandardComposeAppPlugin() error = %v", err)
}

if len(sdk.creates) != 1 {
t.Fatalf("registered creates = %d, want 1", len(sdk.creates))
}
if got := sdk.creates[0].Spec.Name; got != "default" {
t.Fatalf("create spec name = %q, want default", got)
}
if got := sdk.serviceComponentDisplayName; got != "WordPress" {
t.Fatalf("service component display name = %q, want WordPress", got)
}
if len(sdk.serviceComponents) != 2 {
t.Fatalf("registered service components = %d, want 2", len(sdk.serviceComponents))
}
if !sdk.hasHealthcheck {
t.Fatalf("healthcheck was not registered")
}
if !sdk.hasIngressRoutes {
t.Fatalf("ingress route provider was not registered")
}

projectDir := t.TempDir()
compose := []byte("services:\n wordpress:\n image: example/wordpress:latest\n")
if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), compose, 0o600); err != nil {
t.Fatalf("write compose file: %v", err)
}
claim, err := sdk.detectOwnProject(projectDir)
if err != nil {
t.Fatalf("detectOwnProject() error = %v", err)
}
if claim == nil {
t.Fatalf("detectOwnProject() claim = nil, want claim")
}
if got := claim.Plugin; got != "wp" {
t.Fatalf("claim plugin = %q, want wp", got)
}
if got := claim.Reason; got != "wordpress service" {
t.Fatalf("claim reason = %q, want wordpress service", got)
}
}
2 changes: 1 addition & 1 deletion pkg/services/devmode/dev_mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ func assistantService(ctx *config.Context, opts Options, assistant AssistantOpti
harness := normalizeHarness(assistant.Harness)
env := sortedStringMap(assistantEnvironment(ctx, harness, assistant))
service := map[string]any{
"image": "ghcr.io/libops/cli-sandbox:" + harness,
"image": "${SITECTL_ASSISTANT_IMAGE:-ghcr.io/libops/cli-sandbox:" + harness + "}",
"pull_policy": "always",
"profiles": []string{"assistant"},
"working_dir": "/workspace",
Expand Down
4 changes: 2 additions & 2 deletions pkg/services/devmode/dev_mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func TestComponentAssistantWritesCliSandboxService(t *testing.T) {
rendered := readOverrideForTest(t, projectDir)
for _, want := range []string{
"cli-sandbox:",
"image: ghcr.io/libops/cli-sandbox:codex",
"image: ${SITECTL_ASSISTANT_IMAGE:-ghcr.io/libops/cli-sandbox:codex}",
"pull_policy: always",
"- assistant",
"SKIP_EGRESS_FIREWALL: \"false\"",
Expand Down Expand Up @@ -145,7 +145,7 @@ func TestComponentAssistantCanSkipEgressFirewallAndUseEndpointModel(t *testing.T
}
rendered := readOverrideForTest(t, projectDir)
for _, want := range []string{
"image: ghcr.io/libops/cli-sandbox:gemini",
"image: ${SITECTL_ASSISTANT_IMAGE:-ghcr.io/libops/cli-sandbox:gemini}",
"SKIP_EGRESS_FIREWALL: \"true\"",
"OPENAI_BASE_URL: https://models.example/v1",
"OPENAI_API_BASE: https://models.example/v1",
Expand Down
25 changes: 19 additions & 6 deletions scripts/qa-plugin-matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ qa_cloudflare_key="${SITECTL_QA_CLOUDFLARE_KEY:-}"
qa_custom_domain="${SITECTL_QA_CUSTOM_DOMAIN:-${qa_domain}}"
qa_custom_cert="${SITECTL_QA_CUSTOM_CERT:-}"
qa_custom_key="${SITECTL_QA_CUSTOM_KEY:-}"
qa_allow_skips="${SITECTL_QA_ALLOW_SKIPS:-false}"

usage() {
cat <<'USAGE'
Expand All @@ -40,6 +41,7 @@ Environment:
SITECTL_QA_PHASES="default-http domain-http mkcert letsencrypt cloudflare custom"
SITECTL_QA_APPS="wp drupal isle"
SITECTL_QA_KEEP=true
SITECTL_QA_ALLOW_SKIPS=true

Cloudflare/custom TLS phases require:
SITECTL_QA_CLOUDFLARE_CERT=/path/to/cert.pem
Expand All @@ -60,6 +62,17 @@ log() {
printf '\n[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" >&2
}

skip_phase() {
local phase="$1"
local reason="$2"
if [ "$qa_allow_skips" = "true" ]; then
log "skipping ${phase}: ${reason}"
return 0
fi
echo "cannot run ${phase}: ${reason}; set SITECTL_QA_ALLOW_SKIPS=true to allow partial QA" >&2
return 1
}

run() {
log "$*"
"$@"
Expand Down Expand Up @@ -208,8 +221,8 @@ phase_mkcert() {
phase_letsencrypt() {
local ctx="$1"
if [ -z "$qa_acme_email" ]; then
log "skipping letsencrypt: SITECTL_QA_ACME_EMAIL is not set"
return 0
skip_phase "letsencrypt" "SITECTL_QA_ACME_EMAIL is not set"
return $?
fi
set_ingress "$ctx" --mode https-letsencrypt --domain "$qa_domain" --acme-email "$qa_acme_email"
curl_url "https://${qa_domain}/" "$qa_domain" 443 strict
Expand All @@ -219,8 +232,8 @@ phase_cloudflare() {
local ctx="$1"
local dir="$2"
if [ -z "$qa_cloudflare_cert" ] || [ -z "$qa_cloudflare_key" ]; then
log "skipping cloudflare: SITECTL_QA_CLOUDFLARE_CERT and SITECTL_QA_CLOUDFLARE_KEY are not set"
return 0
skip_phase "cloudflare" "SITECTL_QA_CLOUDFLARE_CERT and SITECTL_QA_CLOUDFLARE_KEY are not set"
return $?
fi
install_cert_pair "$dir" "$qa_cloudflare_cert" "$qa_cloudflare_key"
set_ingress "$ctx" --mode https-cloudflare-origin --domain "$qa_cloudflare_domain"
Expand All @@ -231,8 +244,8 @@ phase_custom() {
local ctx="$1"
local dir="$2"
if [ -z "$qa_custom_cert" ] || [ -z "$qa_custom_key" ]; then
log "skipping custom: SITECTL_QA_CUSTOM_CERT and SITECTL_QA_CUSTOM_KEY are not set"
return 0
skip_phase "custom" "SITECTL_QA_CUSTOM_CERT and SITECTL_QA_CUSTOM_KEY are not set"
return $?
fi
install_cert_pair "$dir" "$qa_custom_cert" "$qa_custom_key"
set_ingress "$ctx" --mode https-custom --domain "$qa_custom_domain"
Expand Down