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
10 changes: 7 additions & 3 deletions .goreleaser.desktop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,25 @@ builds:
ldflags:
- -s -w
hooks:
# Generate the bundle Info.plist with the release version for inclusion in the archive.
# Generate the bundle Info.plist (with the release version) and the Dock/Finder icon for
# inclusion in the archive.
post:
- cmd: bash desktop/scripts/make-info-plist.sh "{{ .Version }}" "desktop/build/Info.plist"
- cmd: bash desktop/scripts/make-icns.sh "desktop/resources/icon.png" "desktop/build/AppIcon.icns"

archives:
- id: ksail-desktop
ids: [ksail-desktop]
formats: [zip]
wrap_in_directory: false
name_template: "KSail_{{ .Version }}_darwin_{{ .Arch }}"
# The build emits KSail.app/Contents/MacOS/ksail-desktop; add the Info.plist alongside it so the
# archive holds a complete KSail.app bundle.
# The build emits KSail.app/Contents/MacOS/ksail-desktop; add the Info.plist and AppIcon.icns
# alongside it so the archive holds a complete, icon-bearing KSail.app bundle.
files:
- src: desktop/build/Info.plist
dst: KSail.app/Contents/Info.plist
- src: desktop/build/AppIcon.icns
dst: KSail.app/Contents/Resources/AppIcon.icns

homebrew_casks:
- name: ksail-desktop
Expand Down
Binary file added desktop/resources/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 46 additions & 0 deletions desktop/scripts/make-icns.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Generate the KSail.app icon (AppIcon.icns) from a square source PNG. Shared by make-macos-app.sh
# (local builds) and the desktop GoReleaser config (release builds) so the bundle icon has a single
# source of truth. macOS only: relies on sips + iconutil, which is fine because the .app bundle is
# only ever built on macOS.
#
# Usage: make-icns.sh <source-png> <output-icns>
set -euo pipefail

src="${1:?source PNG path required}"
out="${2:?output .icns path required}"

for tool in sips iconutil; do
command -v "$tool" >/dev/null 2>&1 || {
echo "make-icns.sh: '$tool' not found; the KSail.app icon can only be built on macOS" >&2
exit 1
}
done

mkdir -p "$(dirname "$out")"

iconset="$(mktemp -d)/AppIcon.iconset"
mkdir -p "$iconset"
trap 'rm -rf "$(dirname "$iconset")"' EXIT

# The standard macOS icon sizes (point size + @1x/@2x). sips downsamples crisply; the larger slots
# upsample the source where needed.
for spec in \
"16:icon_16x16" \
"32:icon_16x16@2x" \
"32:icon_32x32" \
"64:icon_32x32@2x" \
"128:icon_128x128" \
"256:icon_128x128@2x" \
"256:icon_256x256" \
"512:icon_256x256@2x" \
"512:icon_512x512" \
"1024:icon_512x512@2x"; do
size="${spec%%:*}"
name="${spec##*:}"
sips -z "$size" "$size" "$src" --out "$iconset/$name.png" >/dev/null
done

iconutil --convert icns "$iconset" --output "$out"

echo "built $out from $src"
1 change: 1 addition & 0 deletions desktop/scripts/make-info-plist.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ cat >"$out" <<PLIST
<key>CFBundleDisplayName</key><string>KSail</string>
<key>CFBundleIdentifier</key><string>tech.devantler.ksail.desktop</string>
<key>CFBundleExecutable</key><string>ksail-desktop</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundleVersion</key><string>${version}</string>
<key>CFBundleShortVersionString</key><string>${version}</string>
<key>CFBundlePackageType</key><string>APPL</string>
Expand Down
3 changes: 3 additions & 0 deletions desktop/scripts/make-macos-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@ chmod +x "$app/Contents/MacOS/$executable"

bash "$script_dir/make-info-plist.sh" "$version" "$app/Contents/Info.plist"

# Generate the Dock/Finder icon (referenced by CFBundleIconFile=AppIcon in the Info.plist).
bash "$script_dir/make-icns.sh" "$script_dir/../resources/icon.png" "$app/Contents/Resources/AppIcon.icns"

echo "built $app (version $version)"
9 changes: 8 additions & 1 deletion pkg/cli/clusterapi/local_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package clusterapi

import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
Expand All @@ -21,6 +22,7 @@ import (
talosconfigmanager "github.com/devantler-tech/ksail/v7/pkg/fsutil/configmanager/talos"
"github.com/devantler-tech/ksail/v7/pkg/operator/api"
clusterprovisioner "github.com/devantler-tech/ksail/v7/pkg/svc/provisioner/cluster"
"github.com/devantler-tech/ksail/v7/pkg/svc/provisioner/cluster/clustererr"
"sigs.k8s.io/kind/pkg/apis/config/v1alpha4"
)

Expand Down Expand Up @@ -286,7 +288,12 @@ func (s *Service) runDelete(ctx context.Context, name string, distribution v1alp
s.mu.Lock()
defer s.mu.Unlock()

if err != nil {
// Deleting must be idempotent. ErrClusterNotFound means there is nothing to delete — a failed
// create that produced no cluster, a cluster removed out-of-band, or a race where it vanished
// between enumeration and Delete. Treat it as success and clear the tracked entry; otherwise the
// job stays pinned Failed and the UI row can never be dismissed (forcing an app restart or a
// fallback to `ksail cluster delete --name`). Only genuine failures are surfaced as Failed.
if err != nil && !errors.Is(err, clustererr.ErrClusterNotFound) {
slog.Error("cluster deletion failed",
"cluster", name, "distribution", distribution, "error", err)

Expand Down
83 changes: 83 additions & 0 deletions pkg/cli/clusterapi/local_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package clusterapi_test

import (
"context"
"errors"
"slices"
"sync"
"testing"
Expand All @@ -11,6 +12,7 @@ import (
"github.com/devantler-tech/ksail/v7/pkg/cli/clusterapi"
"github.com/devantler-tech/ksail/v7/pkg/operator/api"
clusterprovisioner "github.com/devantler-tech/ksail/v7/pkg/svc/provisioner/cluster"
"github.com/devantler-tech/ksail/v7/pkg/svc/provisioner/cluster/clustererr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand All @@ -20,6 +22,13 @@ const (
eventuallyTick = 10 * time.Millisecond
)

// Static sentinel errors used to drive provisioner failures in tests (err113 forbids inline
// errors.New at the call site).
var (
errSimulatedCreateFailure = errors.New("simulated create failure")
errSimulatedDeleteFailure = errors.New("docker refused to remove container")
)
Comment thread
devantler marked this conversation as resolved.

// fakeProvisioner is an in-memory clusterprovisioner.Provisioner. Its List reflects the clusters it
// has created and not yet deleted, so the Service's live enumeration behaves like a real provider.
// Optional gates let tests hold Create/Delete in-flight to observe intermediate phases.
Expand Down Expand Up @@ -67,6 +76,12 @@ func (f *fakeProvisioner) Delete(_ context.Context, name string) error {
return f.deleteErr
}

// Mirror the real provisioners (e.g. Kind): deleting a cluster that does not exist returns
// ErrClusterNotFound rather than silently succeeding.
if !slices.Contains(f.clusters, name) {
return clustererr.ErrClusterNotFound
}

f.clusters = slices.DeleteFunc(f.clusters, func(existing string) bool {
return existing == name
})
Expand Down Expand Up @@ -253,6 +268,74 @@ func TestDeleteIsAsyncAndRemovesCluster(t *testing.T) {
assert.Equal(t, []string{"old"}, provisioner.deletedNames())
}

// TestDeleteClearsFailedClusterWithNoUnderlyingCluster reproduces the bug where a cluster left in
// the Failed phase by a failed create could never be removed from the web UI: deleting it called
// the provisioner's Delete, which returned ErrClusterNotFound (there is no cluster to delete), and
// the entry was pinned Failed forever. Deleting must instead be idempotent and clear the entry.
func TestDeleteClearsFailedClusterWithNoUnderlyingCluster(t *testing.T) {
t.Parallel()

// createErr makes the background create fail, leaving "broken" tracked as Failed with no live
// cluster behind it (List/Exists report it absent, exactly like a half-finished Kind create).
provisioner := &fakeProvisioner{createErr: errSimulatedCreateFailure}
service := newTestService(map[v1alpha1.Distribution]*fakeProvisioner{
v1alpha1.DistributionVanilla: provisioner,
})

_, err := service.Create(
context.Background(),
clusterFor("broken", v1alpha1.DistributionVanilla),
)
require.NoError(t, err)

require.Eventually(t, func() bool {
list, listErr := service.List(context.Background())
require.NoError(t, listErr)

phase, ok := phaseOf(list, "broken")

return ok && phase == v1alpha1.ClusterPhaseFailed
}, eventuallyTimeout, eventuallyTick)

// Deleting the Failed cluster must clear it from the list, not re-pin it as Failed.
require.NoError(t, service.Delete(context.Background(), "default", "broken"))

require.Eventually(t, func() bool {
list, listErr := service.List(context.Background())
require.NoError(t, listErr)

_, present := phaseOf(list, "broken")

return !present
}, eventuallyTimeout, eventuallyTick)
}

// TestDeleteKeepsClusterFailedWhenDeletionErrors ensures a genuine deletion failure (the cluster
// exists but Delete errors) still surfaces as Failed, so real problems are not hidden by the
// idempotent "already gone" handling above.
func TestDeleteKeepsClusterFailedWhenDeletionErrors(t *testing.T) {
t.Parallel()

provisioner := &fakeProvisioner{
clusters: []string{"stuck"},
deleteErr: errSimulatedDeleteFailure,
}
service := newTestService(map[v1alpha1.Distribution]*fakeProvisioner{
v1alpha1.DistributionVanilla: provisioner,
})

require.NoError(t, service.Delete(context.Background(), "default", "stuck"))

require.Eventually(t, func() bool {
list, listErr := service.List(context.Background())
require.NoError(t, listErr)

phase, ok := phaseOf(list, "stuck")

return ok && phase == v1alpha1.ClusterPhaseFailed
}, eventuallyTimeout, eventuallyTick)
}

func TestCreateValidatesInput(t *testing.T) {
t.Parallel()

Expand Down
Loading