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
16 changes: 16 additions & 0 deletions .github/workflows/goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
34 changes: 34 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -23,3 +23,5 @@ lint:
test: build
go test -v -race ./...

publish-cloudsmith:
bash ./scripts/publish-cloudsmith.sh
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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")
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions cmd/config_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/sequelace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 8 additions & 3 deletions pkg/config/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"log"
"log/slog"
"net/url"
"os"
"os/user"
"path/filepath"
Expand Down Expand Up @@ -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()
}
8 changes: 4 additions & 4 deletions pkg/config/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
},
}

Expand Down
28 changes: 26 additions & 2 deletions pkg/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Loading
Loading