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
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ stackctl is a Go CLI tool (Cobra) for managing Kubernetes stack deployments. It
| `config` | set, get, list, use-context, current-context, delete-context | config.go |
| `login`/`logout`/`whoami` | top-level; supports password + OIDC loopback flow | login.go |
| `auth` | register | login.go |
| `stack` | list, get, create, update, deploy, stop, clean, delete, status, logs, clone, extend, values, compare, history, rollback, history-values, watch | stack.go (TTL and stop-time helpers: ttl.go) |
| `stack` | list, get, create, update, deploy, stop, clean, delete, status, logs, clone, extend, values, compare, history, rollback, history-values, watch, preview, pods, recent, follow, unfollow | stack.go; preview/pods/recent: stack_inspect.go; follow/unfollow: stack_followers.go; TTL and stop-time helpers: ttl.go |
| `template` | list, get, create, update, delete, clone, instantiate, quick-deploy, publish, unpublish, update-chart, versions (list, get, diff) | template.go |
| `definition` | list, get, create, update, delete, export, import, update-chart | definition.go |
| `override` | list, get, set, unset, delete, branch (list/set/delete), quota (get/set/delete) | override.go |
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl
login.go # login, logout, whoami, auth register
token.go # Token helpers (save/load/delete session, fresh token for plugins)
stack.go # stack list/get/create/update/deploy/stop/clean/delete/status/logs/clone/extend/values/compare/history/rollback/history-values/watch
stack_inspect.go # stack preview/pods/recent (deploy-preview, live pods and events, recent)
stack_followers.go # stack follow/unfollow (in-app notifications; not the --follow log flag)
ttl.go # --ttl/--stop-at/--until parsing, TTL limit check (GET /api/v1/ui-config, 30-day fallback), expiry text
server.go # server info (ping, ui-config; public routes, no credentials)
template.go # template list/get/create/update/clone/publish/unpublish/versions/instantiate/quick-deploy/delete
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,17 @@ stackctl stack update my-app --ttl 0 # clear the expiry

# Monitor
stackctl stack status my-app
stackctl stack pods my-app # live pods, container reasons (CrashLoopBackOff) and Warning events
stackctl stack logs my-app
stackctl stack recent # your 5 most recently updated stacks

# Helm values that the next deploy uses (YAML on stdout, warnings on stderr)
stackctl stack preview my-app
stackctl stack preview my-app --chart backend > backend-values.yaml

# Get the in-app notifications of a stack (not the --follow flag, which streams a log)
stackctl stack follow my-app
stackctl stack unfollow my-app
stackctl stack watch --id 6a1d2c3b-4e5f-4a6b-8c7d-9e0f1a2b3c4d # watch uses --id, not a name argument

# Lifecycle
Expand Down Expand Up @@ -268,6 +278,8 @@ stackctl stack rollback my-app --target-log <log-id>

`--stop-at` (`stack create`, `clone`, `update`, `template quick-deploy`) sets a fixed stop time: the TTL becomes 0 and the stack stops at that time. `--stop-at` and `--until` accept RFC 3339 (`2026-10-17T18:00:00+02:00`) or a local time without a zone (`2026-10-17 18:00`). stackctl sends the time in UTC. `stack create --ttl 0` now sends 0 (no expiry); without `--ttl` the server applies its default TTL. `--ttl` and `--stop-at` together, a stop time in the past or a bad format are usage errors; stackctl sends no request. Before the request, stackctl also refuses a `--ttl`, a `--stop-at` or an `--until` above the TTL limit of the server (`max_instance_ttl_days` of `GET /api/v1/ui-config`; 30 days for an older server). `stack list` (column `EXPIRES`) and `stack get` show a fixed stop time as `stops at <time>`. A deploy after the stop time fails with 409: set a new stop time or a TTL with `stack update`.

`stack get` shows `Following` and `Followers` (k8s-stack-manager v0.8.0 or later). `stack preview -q` prints the names of the charts whose values differ from the last deploy. `stack preview` writes the values-drift warning to stderr, so the YAML on stdout stays valid.

`stack update --name` must be a DNS label (lowercase a-z, 0-9 and `-`, at most 50 characters). The namespace does not change. When another stack on the cluster has the name, stackctl prints the server message and the free names, for example `Conflict: A stack named "my-app" already exists on this cluster (owner: alice). Free names: my-app-2, my-app-3`. `stack create`, `clone` and `template quick-deploy` print the same message for a name conflict.

`--reset-ttl M` (deprecated) keeps the old behaviour: the TTL becomes M minutes and the expiry now + M minutes, which can be earlier. `--yes` has no effect and is accepted for older scripts.
Expand Down Expand Up @@ -639,8 +651,9 @@ cli/
Makefile # build, build-all, test, lint, coverage, install
cmd/ # Cobra commands, one file per command group
root.go resolve.go paging.go plugins.go browser.go token.go values.go
config.go login.go version.go completion.go
stack.go template.go definition.go override.go bulk.go
config.go login.go version.go completion.go server.go
stack.go stack_inspect.go stack_followers.go ttl.go
template.go definition.go override.go bulk.go
cluster.go git.go orphaned.go user.go apikey.go audit.go
analytics.go cleanup_policy.go favorite.go notification.go
testdata/ # golden files
Expand Down
7 changes: 5 additions & 2 deletions cli/cmd/paging_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,16 @@ func makeIDs(n int) []string {
// resetListFlags resets the shared list flags of cmd when the test ends.
func resetListFlags(t *testing.T, cmd *cobra.Command) {
t.Helper()
t.Cleanup(func() {
// Reset before the test too: another test can leave a flag Changed.
reset := func() {
if cmd.Flags().Lookup("mine") != nil {
resetFlag(t, cmd.Flags(), "mine", "false")
}
resetFlag(t, cmd.Flags(), "page", "0")
resetFlag(t, cmd.Flags(), flagPageSize, "0")
})
}
reset()
t.Cleanup(reset)
}

func TestListCmds_FooterWhenMoreItems(t *testing.T) {
Expand Down
53 changes: 33 additions & 20 deletions cli/cmd/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,29 +356,34 @@ Examples:
case output.FormatYAML:
return printer.PrintYAML(resp)
default:
headers := []string{"ID", "NAME", "STATUS", "OWNER", "BRANCH", "CLUSTER", "DEPLOYED AT", "EXPIRES"}
rows := make([][]string, len(resp.Data))
for i, s := range resp.Data {
cluster := s.ClusterName
if cluster == "" && s.ClusterID != nil {
cluster = *s.ClusterID
}
rows[i] = []string{
s.ID,
s.Name,
printer.StatusColor(s.Status),
displayName(s.OwnerUsername, s.Owner),
s.Branch,
cluster,
formatTime(s.DeployedAt),
expiryText(s.TTLMinutes, s.ExpiresAt),
}
}
return printer.PrintTable(headers, rows)
return printStackTable(resp.Data)
}
},
}

// printStackTable prints stack instances as the table of "stack list".
func printStackTable(items []types.StackInstance) error {
headers := []string{"ID", "NAME", "STATUS", "OWNER", "BRANCH", "CLUSTER", "DEPLOYED AT", "EXPIRES"}
rows := make([][]string, len(items))
for i, s := range items {
cluster := s.ClusterName
if cluster == "" && s.ClusterID != nil {
cluster = *s.ClusterID
}
rows[i] = []string{
s.ID,
s.Name,
printer.StatusColor(s.Status),
displayName(s.OwnerUsername, s.Owner),
s.Branch,
cluster,
formatTime(s.DeployedAt),
expiryText(s.TTLMinutes, s.ExpiresAt),
}
}
return printer.PrintTable(headers, rows)
}

var stackGetCmd = &cobra.Command{
Use: "get <name|id>",
Short: "Show stack instance details",
Expand Down Expand Up @@ -2180,11 +2185,19 @@ func printInstanceWithURLs(instance *types.StackInstance, urls []string) error {
{Key: "Namespace", Value: instance.Namespace},
{Key: "Cluster", Value: displayNameWithID(instance.ClusterName, clusterID)},
{Key: "Definition", Value: displayNameWithID(instance.DefinitionName, instance.StackDefinitionID)},
}
if instance.Following != nil {
fields = append(fields, output.KeyValue{Key: "Following", Value: yesNo(*instance.Following)})
}
if instance.FollowerCount != nil {
fields = append(fields, output.KeyValue{Key: "Followers", Value: strconv.FormatInt(*instance.FollowerCount, 10)})
}
fields = append(fields, []output.KeyValue{
{Key: "TTL", Value: ttlText(instance.TTLMinutes, instance.ExpiresAt)},
{Key: "Expires At", Value: expiryText(instance.TTLMinutes, instance.ExpiresAt)},
{Key: "Deployed At", Value: formatTime(instance.DeployedAt)},
{Key: "Created At", Value: instance.CreatedAt.Format(time.RFC3339)},
}
}...)
if instance.ErrorMessage != "" {
fields = append(fields, output.KeyValue{Key: "Error", Value: instance.ErrorMessage})
}
Expand Down
129 changes: 129 additions & 0 deletions cli/cmd/stack_followers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package cmd

import (
"errors"
"fmt"
"net/http"

"github.com/omattsson/stackctl/cli/pkg/client"
"github.com/omattsson/stackctl/cli/pkg/output"
"github.com/omattsson/stackctl/cli/pkg/types"
"github.com/spf13/cobra"
)

// The subcommands are "follow" and "unfollow". The --follow flag of
// "stack deploy" and "stack logs" streams a log; it is not related.

var stackFollowCmd = &cobra.Command{
Use: "follow <name|id>",
Short: "Follow a stack instance and get its notifications",
Long: `Follow a stack instance. You then get its in-app notifications (deploy,
stop, clean, expiry), as the owner does, filtered by your notification
preferences. Every user who can view a stack can follow it. Following a
stack that you already follow is not an error.

This is not the --follow flag of "stack deploy" and "stack logs", which
streams the deployment log.

Examples:
stackctl stack follow my-stack
stackctl stack follow my-stack -o json`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSetFollow(args[0], true)
},
}

var stackUnfollowCmd = &cobra.Command{
Use: "unfollow <name|id>",
Short: "Stop following a stack instance",
Long: `Stop following a stack instance. Unfollowing a stack that you do not
follow is not an error.

Examples:
stackctl stack unfollow my-stack`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSetFollow(args[0], false)
},
}

// runSetFollow follows or unfollows a stack and prints the new state.
func runSetFollow(nameOrID string, follow bool) error {
c, err := newClient()
if err != nil {
return err
}
id, err := resolveStackID(c, nameOrID)
if err != nil {
return err
}

var state *types.FollowState
if follow {
state, err = c.FollowStack(id)
} else {
state, err = c.UnfollowStack(id)
}
if err != nil {
return followError(err)
}

if printer.Quiet {
fmt.Fprintln(printer.Writer, id)
return nil
}
switch printer.Format {
case output.FormatJSON:
return printer.PrintJSON(state)
case output.FormatYAML:
return printer.PrintYAML(state)
default:
if state.Following {
printer.PrintMessage("You follow stack %s (%s)", id, followerText(state.FollowerCount))
} else {
printer.PrintMessage("You do not follow stack %s (%s)", id, followerText(state.FollowerCount))
}
return nil
}
}

// followError adds a hint to the answer of a server without follow
// support: 404 without a JSON message (no route, before v0.8.0) or 501
// (following is not configured on the server).
func followError(err error) error {
var apiErr *client.APIError
if !errors.As(err, &apiErr) {
return err
}
switch {
case apiErr.StatusCode == http.StatusNotFound && apiErr.Message == http.StatusText(http.StatusNotFound):
return fmt.Errorf("%w (the server does not support following; upgrade k8s-stack-manager to v0.8.0 or later)", err)
case apiErr.StatusCode == http.StatusNotImplemented:
return fmt.Errorf("%w (following is not configured on the server)", err)
}
return err
}

// followerText returns "1 follower" or "N followers".
func followerText(n int64) string {
if n == 1 {
return "1 follower"
}
return fmt.Sprintf("%d followers", n)
}

// yesNo returns "yes" or "no".
func yesNo(b bool) string {
if b {
return "yes"
}
return "no"
}

func init() {
stackCmd.AddCommand(stackFollowCmd)
stackCmd.AddCommand(stackUnfollowCmd)
}
Loading
Loading