diff --git a/.github/workflows/goreleaser.yaml b/.github/workflows/goreleaser.yaml index 15111ed..71b34c8 100644 --- a/.github/workflows/goreleaser.yaml +++ b/.github/workflows/goreleaser.yaml @@ -12,6 +12,12 @@ permissions: jobs: goreleaser: runs-on: ubuntu-24.04 + env: + CLOUDSMITH_NAMESPACE: ${{ vars.CLOUDSMITH_NAMESPACE || 'libops' }} + CLOUDSMITH_REPOSITORY: ${{ vars.CLOUDSMITH_REPOSITORY || 'sitectl' }} + CLOUDSMITH_DEB_TARGETS: ${{ vars.CLOUDSMITH_DEB_TARGETS || 'debian/bookworm ubuntu/noble' }} + CLOUDSMITH_RPM_TARGETS: ${{ vars.CLOUDSMITH_RPM_TARGETS || 'fedora/41' }} + CLOUDSMITH_ALPINE_TARGETS: ${{ vars.CLOUDSMITH_ALPINE_TARGETS || 'alpine/any-version' }} steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 @@ -31,3 +37,13 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.HOMEBREW_REPO }} + + - name: Install Cloudsmith CLI + if: ${{ secrets.CLOUDSMITH_API_KEY != '' }} + uses: cloudsmith-io/cloudsmith-cli-action@v1 + with: + api-key: ${{ secrets.CLOUDSMITH_API_KEY }} + + - name: Publish Linux packages to Cloudsmith + if: ${{ secrets.CLOUDSMITH_API_KEY != '' }} + run: make publish-cloudsmith diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 601f451..885d9eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,3 +67,37 @@ That means a command UI should be structured so it can be: - pushed or mounted inside the dashboard TUI If a proposed command UI cannot be reused that way, it should be redesigned before being added. + +## Release Publishing + +GoReleaser builds the release artifacts, including Linux packages via `nfpms`. + +- GitHub release publishing runs from [`.github/workflows/goreleaser.yaml`](/workspace/sitectl/.github/workflows/goreleaser.yaml) +- Cloudsmith publishing is handled by [`scripts/publish-cloudsmith.sh`](/workspace/sitectl/scripts/publish-cloudsmith.sh) +- CI and local publishing should both use `make publish-cloudsmith` + +### Cloudsmith + +To enable Cloudsmith uploads in GitHub Actions, set: + +- secret `CLOUDSMITH_API_KEY` +- variable `CLOUDSMITH_NAMESPACE` such as `libops` +- variable `CLOUDSMITH_REPOSITORY` such as `sitectl` + +Optional target overrides: + +- `CLOUDSMITH_DEB_TARGETS` default: `debian/bookworm ubuntu/noble` +- `CLOUDSMITH_RPM_TARGETS` default: `fedora/41` +- `CLOUDSMITH_ALPINE_TARGETS` default: `alpine/any-version` + +To publish an already-built `dist/` directory locally: + +```bash +CLOUDSMITH_API_KEY=... \ +CLOUDSMITH_NAMESPACE=libops \ +CLOUDSMITH_REPOSITORY=sitectl \ +CLOUDSMITH_DEB_TARGETS="debian/bookworm ubuntu/noble" \ +CLOUDSMITH_RPM_TARGETS="fedora/41" \ +CLOUDSMITH_ALPINE_TARGETS="alpine/any-version" \ +make publish-cloudsmith +``` diff --git a/Makefile b/Makefile index be5b0e7..1c8b727 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build deps lint test docker integration-test docs plugins install-plugins +.PHONY: build deps lint test docker integration-test docs plugins install-plugins publish-cloudsmith BINARY_NAME=sitectl @@ -23,3 +23,5 @@ lint: test: build go test -v -race ./... +publish-cloudsmith: + bash ./scripts/publish-cloudsmith.sh diff --git a/README.md b/README.md index ca6b812..4f001da 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ Releases also publish native Linux packages for: - Debian and Ubuntu (`.deb`) - Fedora and other RPM-based distributions (`.rpm`) +[![OSS hosting by Cloudsmith](https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith&style=flat-square)](https://cloudsmith.com) + +Linux package repository hosting is graciously provided by [Cloudsmith](https://cloudsmith.com). + ## Usage ```bash diff --git a/cmd/config.go b/cmd/config.go index 49862f4..690bd82 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -537,9 +537,6 @@ func runCreateConfig(cmd *cobra.Command, args []string) error { } } context.Name = args[0] - if strings.TrimSpace(context.Site) == "" { - context.Site = firstNonEmptyString(context.ProjectName, context.Name) - } if strings.TrimSpace(context.Plugin) == "" { context.Plugin = "core" } @@ -591,6 +588,9 @@ func runCreateConfig(cmd *cobra.Command, args []string) error { context.Plugin = selectedPlugin } } + if !f.Changed("project-name") && placeholderProjectName(context.ProjectName) { + context.ProjectName = firstNonEmptyString(filepath.Base(context.ProjectDir), "docker-compose") + } if strings.TrimSpace(context.ProjectName) == "" { context.ProjectName = firstNonEmptyString(filepath.Base(context.ProjectDir), "docker-compose") } @@ -600,8 +600,11 @@ func runCreateConfig(cmd *cobra.Command, args []string) error { if !f.Changed("compose-network") && strings.TrimSpace(context.ComposeNetwork) == "" { context.ComposeNetwork = config.DetectComposeNetworkName(context.ProjectDir, context.EffectiveComposeProjectName()) } + if !f.Changed("site") && placeholderProjectName(context.Site) { + context.Site = firstNonEmptyString(filepath.Base(context.ProjectDir), context.ProjectName, context.Name) + } if strings.TrimSpace(context.Site) == "" { - context.Site = firstNonEmptyString(context.ProjectName, context.Name) + context.Site = firstNonEmptyString(filepath.Base(context.ProjectDir), context.ProjectName, context.Name) } if context.DockerHostType == config.ContextRemote { @@ -640,6 +643,11 @@ func runCreateConfig(cmd *cobra.Command, args []string) error { return nil } +func placeholderProjectName(value string) bool { + value = strings.TrimSpace(value) + return value == "" || value == "docker-compose" +} + func promptContextPlugin(defaultPlugin string) (string, error) { choices := []corecomponent.Choice{{ Value: "core", diff --git a/cmd/config_create_test.go b/cmd/config_create_test.go index 2e8e89e..71b8ece 100644 --- a/cmd/config_create_test.go +++ b/cmd/config_create_test.go @@ -318,6 +318,108 @@ func TestRunCreateConfigPromptsForContextWhenCwdIsNotComposeProject(t *testing.T if ctx.DockerHostType != config.ContextLocal { t.Fatalf("expected local context, got %q", ctx.DockerHostType) } + if ctx.Site != "existing-site" { + t.Fatalf("expected site existing-site, got %q", ctx.Site) + } + if ctx.ProjectName != "existing-site" { + t.Fatalf("expected project name existing-site, got %q", ctx.ProjectName) + } + if ctx.ComposeProjectName != "existing-site" { + t.Fatalf("expected compose project name existing-site, got %q", ctx.ComposeProjectName) + } +} + +func TestRunCreateConfigUsesDetectedComposeProjectNameWithoutKeepingDockerComposePlaceholder(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + projectDir := filepath.Join(tempHome, "isle-preserve") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("MkdirAll(projectDir) error = %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { + t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, ".env"), []byte("COMPOSE_PROJECT_NAME=lehigh-d10\n"), 0o644); err != nil { + t.Fatalf("WriteFile(.env) error = %v", err) + } + + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(tempHome); err != nil { + t.Fatalf("Chdir(tempHome) error = %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldWd) + }) + + oldInput := createConfigInput + oldPromptChoice := createConfigPromptChoice + oldDiscoverPlugins := createConfigDiscoverPlugins + oldVerifyRemote := createConfigVerifyRemote + oldProjectDirExists := createConfigProjectDirExists + oldRunComposePS := createConfigRunComposePS + t.Cleanup(func() { + createConfigInput = oldInput + createConfigPromptChoice = oldPromptChoice + createConfigDiscoverPlugins = oldDiscoverPlugins + createConfigVerifyRemote = oldVerifyRemote + createConfigProjectDirExists = oldProjectDirExists + createConfigRunComposePS = oldRunComposePS + }) + + createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } + createConfigVerifyRemote = func(ctx *config.Context) error { return nil } + createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } + createConfigRunComposePS = func(ctx *config.Context) error { return nil } + createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { + if name == "plugin" { + return "isle", nil + } + if name == "add-environment" { + return "no", nil + } + t.Fatalf("unexpected choice prompt: %s", name) + return "", nil + } + + prompts := []string{ + "isle-preserve-local", + "", + projectDir, + } + createConfigInput = func(question ...string) (string, error) { + if len(prompts) == 0 { + t.Fatalf("unexpected prompt: %v", question) + } + value := prompts[0] + prompts = prompts[1:] + return value, nil + } + + cmd := &cobra.Command{Use: "create"} + config.SetCommandFlags(cmd.Flags()) + cmd.Flags().Bool("default", true, "") + + if err := runCreateConfig(cmd, nil); err != nil { + t.Fatalf("runCreateConfig() error = %v", err) + } + + ctx, err := config.GetContext("isle-preserve-local") + if err != nil { + t.Fatalf("GetContext(isle-preserve-local) error = %v", err) + } + if ctx.Site != "isle-preserve" { + t.Fatalf("expected site isle-preserve, got %q", ctx.Site) + } + if ctx.ProjectName != "isle-preserve" { + t.Fatalf("expected project name isle-preserve, got %q", ctx.ProjectName) + } + if ctx.ComposeProjectName != "lehigh-d10" { + t.Fatalf("expected compose project name lehigh-d10, got %q", ctx.ComposeProjectName) + } } func TestRunCreateConfigSkipsTypeAndProjectDirPromptsWhenFlagsProvided(t *testing.T) { diff --git a/cmd/sequelace.go b/cmd/sequelace.go index d821440..10650fb 100644 --- a/cmd/sequelace.go +++ b/cmd/sequelace.go @@ -37,9 +37,9 @@ var sequelAceCmd = &cobra.Command{ } slog.Debug("uris", "mysql", mysql, "ssh", ssh) cmdArgs := []string{ - fmt.Sprintf("%s?%s", mysql, ssh), "-a", sequelAcePath, + fmt.Sprintf("%s?%s", mysql, ssh), } openCmd := exec.Command("open", cmdArgs...) if err := openCmd.Run(); err != nil { diff --git a/pkg/config/context.go b/pkg/config/context.go index 0d43e22..d943a47 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -6,6 +6,7 @@ import ( "io" "log" "log/slog" + "net/url" "os" "os/user" "path/filepath" @@ -502,10 +503,14 @@ func (c *Context) GetSshUri() string { sshPort = 22 } - sshParams := fmt.Sprintf("sshHost=%s&sshUser=%s&sshPort=%d", c.SSHHostname, c.SSHUser, sshPort) + values := url.Values{} + values.Set("ssh_host", c.SSHHostname) + values.Set("ssh_user", c.SSHUser) + values.Set("ssh_port", strconv.FormatUint(uint64(sshPort), 10)) if c.SSHKeyPath != "" { - sshParams += fmt.Sprintf("&sshKeyFile=%s", c.SSHKeyPath) + values.Set("ssh_keyLocation", c.SSHKeyPath) + values.Set("ssh_keyLocationEnabled", "1") } - return sshParams + return values.Encode() } diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go index e7a11fb..dbf97a4 100644 --- a/pkg/config/context_test.go +++ b/pkg/config/context_test.go @@ -430,7 +430,7 @@ func TestGetSshUri(t *testing.T) { SSHUser: "testuser", SSHPort: 0, // Should default to 22 }, - expected: "sshHost=example.com&sshUser=testuser&sshPort=22", + expected: "ssh_host=example.com&ssh_port=22&ssh_user=testuser", }, { name: "remote context with custom port", @@ -440,7 +440,7 @@ func TestGetSshUri(t *testing.T) { SSHUser: "testuser", SSHPort: 2222, }, - expected: "sshHost=example.com&sshUser=testuser&sshPort=2222", + expected: "ssh_host=example.com&ssh_port=2222&ssh_user=testuser", }, { name: "remote context with SSH key path", @@ -451,7 +451,7 @@ func TestGetSshUri(t *testing.T) { SSHPort: 22, SSHKeyPath: "/home/user/.ssh/id_rsa", }, - expected: "sshHost=example.com&sshUser=testuser&sshPort=22&sshKeyFile=/home/user/.ssh/id_rsa", + expected: "ssh_host=example.com&ssh_keyLocation=%2Fhome%2Fuser%2F.ssh%2Fid_rsa&ssh_keyLocationEnabled=1&ssh_port=22&ssh_user=testuser", }, { name: "remote context without SSH key path", @@ -462,7 +462,7 @@ func TestGetSshUri(t *testing.T) { SSHPort: 22, SSHKeyPath: "", }, - expected: "sshHost=server.example.com&sshUser=admin&sshPort=22", + expected: "ssh_host=server.example.com&ssh_port=22&ssh_user=admin", }, } diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go index a4c84cd..b276aad 100644 --- a/pkg/docker/docker.go +++ b/pkg/docker/docker.go @@ -6,10 +6,12 @@ import ( "io" "log/slog" "net" + "net/url" "net/http" "os" "path/filepath" "sort" + "strconv" "strings" dockercontainer "github.com/docker/docker/api/types/container" @@ -306,6 +308,12 @@ func GetDatabaseUris(c *config.Context) (string, string, error) { } defer dockerCli.Close() + return getDatabaseURIsWithClient(ctx, dockerCli, c) +} + +func getDatabaseURIsWithClient(ctx context.Context, dockerCli *DockerClient, c *config.Context) (string, string, error) { + dbHost := "127.0.0.1" + // Get the database container name containerName, err := dockerCli.GetContainerName(c, c.DatabaseService) if err != nil { @@ -315,12 +323,28 @@ func GetDatabaseUris(c *config.Context) (string, string, error) { return "", "", fmt.Errorf("%s container not found", c.DatabaseService) } + if c.DockerHostType == config.ContextRemote { + dbHost, err = dockerCli.GetServiceIp(ctx, c, containerName) + if err != nil { + return "", "", fmt.Errorf("failed to resolve %s service IP: %w", c.DatabaseService, err) + } + if dbHost == "" { + return "", "", fmt.Errorf("resolved empty IP for %s service", c.DatabaseService) + } + } + // Get database password from container environment password, err := GetSecret(ctx, dockerCli.CLI, c, containerName, c.DatabasePasswordSecret) if err != nil { return "", "", fmt.Errorf("failed to get database password from %s: %w", c.DatabasePasswordSecret, err) } - mysqlURI := fmt.Sprintf("mysql://%s:%s@127.0.0.1:3306/%s", c.DatabaseUser, password, c.DatabaseName) - return mysqlURI, c.GetSshUri(), nil + mysqlURI := url.URL{ + Scheme: "mysql", + User: url.UserPassword(c.DatabaseUser, password), + Host: net.JoinHostPort(dbHost, strconv.Itoa(3306)), + Path: "/" + c.DatabaseName, + } + + return mysqlURI.String(), c.GetSshUri(), nil } diff --git a/pkg/docker/docker_test.go b/pkg/docker/docker_test.go index 0bcf770..e66580f 100644 --- a/pkg/docker/docker_test.go +++ b/pkg/docker/docker_test.go @@ -3,6 +3,7 @@ package docker import ( "context" "fmt" + "net/url" "path/filepath" "strings" "testing" @@ -195,3 +196,104 @@ func TestGetServiceIpFallsBackToSingleNetwork(t *testing.T) { t.Errorf("expected %q, got %q", "172.17.0.9", ip) } } + +func TestGetDatabaseURIsWithClient_LocalContextUsesLoopback(t *testing.T) { + fake := &FakeDockerClient{ + ListFunc: func(ctx context.Context, options dockercontainer.ListOptions) ([]dockercontainer.Summary, error) { + return []dockercontainer.Summary{{Names: []string{"/stack-mariadb-1"}}}, nil + }, + InspectFunc: func(ctx context.Context, container string) (dockercontainer.InspectResponse, error) { + return dockercontainer.InspectResponse{ + Config: &dockercontainer.Config{ + Env: []string{"DB_ROOT_PASSWORD=secret"}, + }, + }, nil + }, + } + ctxCfg := &config.Context{ + DockerHostType: config.ContextLocal, + ProjectName: "stack", + DatabaseService: "mariadb", + DatabaseUser: "root", + DatabasePasswordSecret: "DB_ROOT_PASSWORD", + DatabaseName: "drupal_default", + } + + mysqlURI, sshURI, err := getDatabaseURIsWithClient(context.Background(), &DockerClient{CLI: fake}, ctxCfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sshURI != "" { + t.Fatalf("expected empty ssh URI for local context, got %q", sshURI) + } + parsed, err := url.Parse(mysqlURI) + if err != nil { + t.Fatalf("failed to parse mysql URI %q: %v", mysqlURI, err) + } + if parsed.Host != "127.0.0.1:3306" { + t.Fatalf("expected loopback host, got %q", parsed.Host) + } + if parsed.User.Username() != "root" { + t.Fatalf("expected root user, got %q", parsed.User.Username()) + } + password, ok := parsed.User.Password() + if !ok || password != "secret" { + t.Fatalf("expected password secret, got %q", password) + } + if parsed.Path != "/drupal_default" { + t.Fatalf("expected /drupal_default path, got %q", parsed.Path) + } +} + +func TestGetDatabaseURIsWithClient_RemoteContextUsesContainerIP(t *testing.T) { + fake := &FakeDockerClient{ + ListFunc: func(ctx context.Context, options dockercontainer.ListOptions) ([]dockercontainer.Summary, error) { + return []dockercontainer.Summary{{Names: []string{"/stack-mariadb-1"}}}, nil + }, + InspectFunc: func(ctx context.Context, container string) (dockercontainer.InspectResponse, error) { + return dockercontainer.InspectResponse{ + Config: &dockercontainer.Config{ + Env: []string{"DB_ROOT_PASSWORD=p@ss word"}, + }, + NetworkSettings: &dockercontainer.NetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "stack_default": {IPAddress: "172.22.0.5"}, + }, + }, + }, nil + }, + } + ctxCfg := &config.Context{ + DockerHostType: config.ContextRemote, + ProjectName: "stack", + ComposeNetwork: "stack_default", + DatabaseService: "mariadb", + DatabaseUser: "root", + DatabasePasswordSecret: "DB_ROOT_PASSWORD", + DatabaseName: "drupal_default", + SSHHostname: "db.example.com", + SSHUser: "deploy", + SSHPort: 2222, + SSHKeyPath: "/Users/test/.ssh/id_ed25519", + } + + mysqlURI, sshURI, err := getDatabaseURIsWithClient(context.Background(), &DockerClient{CLI: fake}, ctxCfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + parsed, err := url.Parse(mysqlURI) + if err != nil { + t.Fatalf("failed to parse mysql URI %q: %v", mysqlURI, err) + } + if parsed.Host != "172.22.0.5:3306" { + t.Fatalf("expected container IP host, got %q", parsed.Host) + } + password, ok := parsed.User.Password() + if !ok || password != "p@ss word" { + t.Fatalf("expected decoded password, got %q", password) + } + expectedSSH := "ssh_host=db.example.com&ssh_keyLocation=%2FUsers%2Ftest%2F.ssh%2Fid_ed25519&ssh_keyLocationEnabled=1&ssh_port=2222&ssh_user=deploy" + if sshURI != expectedSSH { + t.Fatalf("expected ssh URI %q, got %q", expectedSSH, sshURI) + } +} diff --git a/scripts/publish-cloudsmith.sh b/scripts/publish-cloudsmith.sh new file mode 100644 index 0000000..7684ecf --- /dev/null +++ b/scripts/publish-cloudsmith.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -euo pipefail +shopt -s nullglob + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}" + +: "${CLOUDSMITH_NAMESPACE:?CLOUDSMITH_NAMESPACE is required}" +: "${CLOUDSMITH_REPOSITORY:?CLOUDSMITH_REPOSITORY is required}" + +publish_packages() { + local format="$1" + local pattern="$2" + local targets="$3" + local -a packages=() + + while IFS= read -r package; do + packages+=("$package") + done < <(find "$DIST_DIR" -maxdepth 1 -type f -name "$pattern" -print | sort) + + if [ ${#packages[@]} -eq 0 ]; then + echo "No ${format} packages found in ${DIST_DIR}/" + return 0 + fi + if [ -z "${targets// }" ]; then + echo "No Cloudsmith targets configured for ${format}; skipping" + return 0 + fi + + for package in "${packages[@]}"; do + for target in $targets; do + cloudsmith push "$format" "$CLOUDSMITH_NAMESPACE/$CLOUDSMITH_REPOSITORY/$target" "$package" --no-wait-for-sync + done + done +} + +publish_packages "deb" "*.deb" "${CLOUDSMITH_DEB_TARGETS:-}" +publish_packages "rpm" "*.rpm" "${CLOUDSMITH_RPM_TARGETS:-}" +publish_packages "alpine" "*.apk" "${CLOUDSMITH_ALPINE_TARGETS:-}"