From becb3dbfbe9b31a28514e6cfe1f032649591c020 Mon Sep 17 00:00:00 2001 From: "H.E. Pennypacker" <115990865+pennypacker-he@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:49:23 +0000 Subject: [PATCH] [minor] Add standard compose app plugin registration Add a reusable SDK registration helper for compose-backed application plugins, allow dev-mode assistant images to be pinned through SITECTL_ASSISTANT_IMAGE, and make the plugin QA matrix fail skipped TLS phases unless explicitly allowed. --- pkg/plugin/standard_app_plugin.go | 143 +++++++++++++++++++++++++ pkg/plugin/standard_app_plugin_test.go | 85 +++++++++++++++ pkg/services/devmode/dev_mode.go | 2 +- pkg/services/devmode/dev_mode_test.go | 4 +- scripts/qa-plugin-matrix.sh | 25 +++-- 5 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 pkg/plugin/standard_app_plugin.go create mode 100644 pkg/plugin/standard_app_plugin_test.go diff --git a/pkg/plugin/standard_app_plugin.go b/pkg/plugin/standard_app_plugin.go new file mode 100644 index 0000000..8e8397d --- /dev/null +++ b/pkg/plugin/standard_app_plugin.go @@ -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) + } +} diff --git a/pkg/plugin/standard_app_plugin_test.go b/pkg/plugin/standard_app_plugin_test.go new file mode 100644 index 0000000..1b82c27 --- /dev/null +++ b/pkg/plugin/standard_app_plugin_test.go @@ -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) + } +} diff --git a/pkg/services/devmode/dev_mode.go b/pkg/services/devmode/dev_mode.go index 43260a3..36becd8 100644 --- a/pkg/services/devmode/dev_mode.go +++ b/pkg/services/devmode/dev_mode.go @@ -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", diff --git a/pkg/services/devmode/dev_mode_test.go b/pkg/services/devmode/dev_mode_test.go index 1311f47..e61e93d 100644 --- a/pkg/services/devmode/dev_mode_test.go +++ b/pkg/services/devmode/dev_mode_test.go @@ -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\"", @@ -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", diff --git a/scripts/qa-plugin-matrix.sh b/scripts/qa-plugin-matrix.sh index c14a2fb..26825d2 100755 --- a/scripts/qa-plugin-matrix.sh +++ b/scripts/qa-plugin-matrix.sh @@ -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' @@ -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 @@ -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 "$*" "$@" @@ -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 @@ -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" @@ -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"