diff --git a/GNUmakefile b/GNUmakefile index 748d7580c..5bdc0757b 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -50,6 +50,9 @@ PROBOD_SRC= cmd/probod/main.go PRB_BIN= bin/prb PRB_SRC= cmd/prb/main.go +PROBOCTL_BIN= bin/proboctl +PROBOCTL_SRC= cmd/proboctl/main.go + PROBOD_BOOTSTRAP_BIN= bin/probod-bootstrap PROBOD_BOOTSTRAP_SRC= cmd/probod-bootstrap/main.go @@ -149,7 +152,7 @@ coverage-combined: coverage-report test-e2e-coverage ## Generate combined covera $(GO) tool cover -html=coverage-combined.out -o=coverage-combined.html .PHONY: build -build: bin/probod bin/prb bin/probod-bootstrap +build: bin/probod bin/prb bin/proboctl bin/probod-bootstrap CFG_DEV_OAUTH2_KEY = cfg/.dev-oauth2-signing-key.pem DEV_ENV = .env @@ -236,6 +239,10 @@ bin/probod: pkg/server/api/connect/v1/schema/schema.go \ bin/prb: $(GO_BUILD) -o $(PRB_BIN) $(PRB_SRC) +.PHONY: bin/proboctl +bin/proboctl: + $(GO_BUILD) -o $(PROBOCTL_BIN) $(PROBOCTL_SRC) + .PHONY: bin/probod-bootstrap bin/probod-bootstrap: $(GO_BUILD) -o $(PROBOD_BOOTSTRAP_BIN) $(PROBOD_BOOTSTRAP_SRC) diff --git a/cmd/proboctl/main.go b/cmd/proboctl/main.go new file mode 100644 index 000000000..60fdc5849 --- /dev/null +++ b/cmd/proboctl/main.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "fmt" + "os" + + "go.probo.inc/probo/pkg/proboctl" +) + +var ( + version string = "unknown" +) + +func main() { + cmd := proboctl.NewRootCmd(version) + + if err := cmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + os.Exit(1) + } +} diff --git a/pkg/proboctl/cookiebanner/cookiebanner.go b/pkg/proboctl/cookiebanner/cookiebanner.go new file mode 100644 index 000000000..e0f12d7af --- /dev/null +++ b/pkg/proboctl/cookiebanner/cookiebanner.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cookiebanner + +import ( + "context" + "fmt" + "strconv" + + "github.com/spf13/cobra" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/cookiebanner" + "go.probo.inc/probo/pkg/gid" +) + +type pgClientFactory func() (*pg.Client, error) + +func NewCmdCookieBanner(newPG pgClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "cookie-banner ", + Short: "Manage cookie banners", + } + + cmd.AddCommand(newCmdSetBranding(newPG)) + + return cmd +} + +func newCmdSetBranding(newPG pgClientFactory) *cobra.Command { + return &cobra.Command{ + Use: "set-branding ", + Short: "Enable or disable 'Powered by Probo' branding on a banner", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + bannerID, err := gid.ParseGID(args[0]) + if err != nil { + return fmt.Errorf("invalid banner ID: %w", err) + } + + show, err := strconv.ParseBool(args[1]) + if err != nil { + return fmt.Errorf("invalid boolean value %q: use true or false", args[1]) + } + + pgClient, err := newPG() + if err != nil { + return fmt.Errorf("cannot connect to database: %w", err) + } + defer pgClient.Close() + + svc := cookiebanner.NewService(pgClient) + if err := svc.SetShowBranding(context.Background(), bannerID, show); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "show_branding set to %v for banner %s\n", show, bannerID) + return nil + }, + } +} diff --git a/pkg/proboctl/root.go b/pkg/proboctl/root.go new file mode 100644 index 000000000..bfe6fc4b6 --- /dev/null +++ b/pkg/proboctl/root.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package proboctl + +import ( + "fmt" + "os" + "strconv" + + "github.com/jackc/pgx/v5" + "github.com/spf13/cobra" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/proboctl/cookiebanner" +) + +func NewRootCmd(version string) *cobra.Command { + var pgDSN string + + cmd := &cobra.Command{ + Use: "proboctl [flags]", + Short: "Probo internal admin CLI", + Long: "proboctl is an internal tool for Probo staff to manage parameters not exposed through the public API.", + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.PersistentFlags().StringVar(&pgDSN, "pg-dsn", "", "PostgreSQL connection string (also PG_DSN env var)") + + newPG := func() (*pg.Client, error) { + return newPGClient(pgDSN) + } + + cmd.AddCommand(cookiebanner.NewCmdCookieBanner(newPG)) + + return cmd +} + +func newPGClient(flagDSN string) (*pg.Client, error) { + dsn := flagDSN + if dsn == "" { + dsn = os.Getenv("PG_DSN") + } + + var addr, user, pass, db string + + if dsn != "" { + cfg, err := pgx.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("invalid DSN: %w", err) + } + addr = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + user = cfg.User + pass = cfg.Password + db = cfg.Database + } else { + addr = envOrDefault("PG_ADDR", "localhost:5432") + user = envOrDefault("PG_USERNAME", "postgres") + pass = envOrDefault("PG_PASSWORD", "postgres") + db = envOrDefault("PG_DATABASE", "probod") + } + + poolSize := envIntOrDefault("PG_POOL_SIZE", 2) + + opts := []pg.Option{ + pg.WithAddr(addr), + pg.WithUser(user), + pg.WithPassword(pass), + pg.WithDatabase(db), + pg.WithPoolSize(int32(poolSize)), + } + + return pg.NewClient(opts...) +} + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envIntOrDefault(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + fmt.Fprintf(os.Stderr, "warning: invalid %s value %q, using default %d\n", key, v, fallback) + } + return fallback +}