From e728f5865b7d87fce35b9051a5d6ffc09c61305f Mon Sep 17 00:00:00 2001 From: Henrique Mouta Date: Thu, 15 Jan 2026 12:23:18 +0000 Subject: [PATCH 1/3] Expose each secret status on the secrets list command --- internal/command/secrets/list.go | 368 +++++++++++++++++++++++++- internal/command/secrets/list_test.go | 235 ++++++++++++++++ 2 files changed, 592 insertions(+), 11 deletions(-) create mode 100644 internal/command/secrets/list_test.go diff --git a/internal/command/secrets/list.go b/internal/command/secrets/list.go index 13bcaf2a4d..8a007deaed 100644 --- a/internal/command/secrets/list.go +++ b/internal/command/secrets/list.go @@ -2,8 +2,13 @@ package secrets import ( "context" + "fmt" + "io" + "strconv" + "time" "github.com/spf13/cobra" + fly "github.com/superfly/fly-go" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/appsecrets" "github.com/superfly/flyctl/internal/command" @@ -11,15 +16,46 @@ import ( "github.com/superfly/flyctl/internal/flag" "github.com/superfly/flyctl/internal/flapsutil" "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/internal/uiexutil" "github.com/superfly/flyctl/iostreams" ) +// Maximum number of machines to check for deployment status +const maxMachinesToCheck = 100 + +// SecretStatus represents the deployment status of a secret +type SecretStatus string + +const ( + StatusDeployed SecretStatus = "Deployed" + StatusStaged SecretStatus = "Staged" + StatusPartiallyDeployed SecretStatus = "Partial" + StatusUnknown SecretStatus = "Unknown" +) + +// SecretWithStatus extends fly.AppSecret with deployment status +type SecretWithStatus struct { + Name string `json:"name"` + Digest string `json:"digest"` + Status SecretStatus `json:"status"` +} + func newList() (cmd *cobra.Command) { const ( long = `List the secrets available to the application. It shows each secret's -name, a digest of its value and the time the secret was last set. The -actual value of the secret is only available to the application.` - short = `List application secret names, digests and creation times` +name, a digest of its value and the deployment status across machines. The +actual value of the secret is only available to the application. + +Secrets that need deployment are prefixed with an indicator: + * Staged secret (not deployed to any machines) + ! Partial deployment (deployed to some but not all machines) + +Deployment status: + Deployed - Secret is deployed to all machines (secret updated_at <= machine release created_at) + Staged - Secret is staged but not deployed to any machines + Partial - Secret is deployed to some but not all machines (rolling deployment in progress) + Unknown - Status cannot be determined (missing timestamps, too many machines, or API error)` + short = `List application secret names, digests and deployment status` usage = "list [flags]" ) @@ -39,31 +75,341 @@ actual value of the secret is only available to the application.` func runList(ctx context.Context) (err error) { appName := appconfig.NameFromContext(ctx) flapsClient := flapsutil.ClientFromContext(ctx) + uiexClient := uiexutil.ClientFromContext(ctx) cfg := config.FromContext(ctx) out := iostreams.FromContext(ctx).Out - secrets, err := appsecrets.List(ctx, flapsClient, appName) + rows, secretsWithStatus, stagedCount, partialCount, statusAvailable, err := buildSecretRows(ctx, flapsClient, uiexClient, appName) if err != nil { return err } + headers := []string{"Name", "Digest"} + if statusAvailable { + headers = append(headers, "Status") + } + + if cfg.JSONOutput { + if statusAvailable { + return render.JSON(out, secretsWithStatus) + } + // When status is not available, build JSON from secrets + type secretBasic struct { + Name string `json:"name"` + Digest string `json:"digest"` + } + basicSecrets := make([]secretBasic, len(rows)) + for i, row := range rows { + basicSecrets[i] = secretBasic{Name: row[0], Digest: row[1]} + } + return render.JSON(out, basicSecrets) + } + + if err := render.Table(out, "", rows, headers...); err != nil { + return err + } + + if stagedCount > 0 || partialCount > 0 { + printDeploymentSummary(out, stagedCount, partialCount) + } + + return nil +} + +func buildSecretRows(ctx context.Context, flapsClient flapsutil.FlapsClient, uiexClient uiexutil.Client, appName string) ([][]string, []SecretWithStatus, int, int, bool, error) { + secrets, err := appsecrets.List(ctx, flapsClient, appName) + if err != nil { + return nil, nil, 0, 0, false, err + } + + // Get machines to compute deployment status + machines, _, err := flapsClient.ListFlyAppsMachines(ctx, appName) + if err != nil { + // If we can't get machines, show secrets without status + machines = nil + } + + // Filter out destroyed/destroying machines + relevantMachines := filterRelevantMachines(machines) + + // Skip status computation if too many machines - just show name and digest + if len(relevantMachines) > maxMachinesToCheck { + return buildRowsWithoutStatus(secrets), nil, 0, 0, false, nil + } + + // Fetch release timestamps + releaseTimestamps := fetchReleaseTimestamps(ctx, uiexClient, appName, collectReleaseVersions(relevantMachines)) + + // Pre-compute version counts + versionCounts := buildVersionCounts(relevantMachines, releaseTimestamps) + + // Compute per-secret deployment status and count staged and partially deployed secrets + secretsWithStatus := make([]SecretWithStatus, 0, len(secrets)) + stagedCount := 0 + partialCount := 0 + for _, secret := range secrets { + status := computeSecretStatus(secret, versionCounts) + if status == StatusStaged { + stagedCount++ + } + if status == StatusPartiallyDeployed { + partialCount++ + } + secretsWithStatus = append(secretsWithStatus, SecretWithStatus{ + Name: secret.Name, + Digest: secret.Digest, + Status: status, + }) + } + var rows [][]string + for _, secret := range secretsWithStatus { + var prefix string + switch secret.Status { + case StatusStaged: + prefix = "* " + case StatusPartiallyDeployed: + prefix = "! " + } + + rows = append(rows, []string{ + prefix + secret.Name, + secret.Digest, + string(secret.Status), + }) + } + return rows, secretsWithStatus, stagedCount, partialCount, true, nil +} + +func buildRowsWithoutStatus(secrets []fly.AppSecret) [][]string { + rows := make([][]string, 0, len(secrets)) for _, secret := range secrets { rows = append(rows, []string{ secret.Name, secret.Digest, }) } + return rows +} - headers := []string{ - "Name", - "Digest", +func printDeploymentSummary(out io.Writer, stagedCount, partialCount int) { + if stagedCount == 0 && partialCount == 0 { + return } - if cfg.JSONOutput { - return render.JSON(out, secrets) - } else { - return render.Table(out, "", rows, headers...) + + if stagedCount > 0 && partialCount > 0 { + // Both staged and partial - use bullet list format + fmt.Fprintf(out, "Some secrets need to be deployed:\n") + + stagedPlural := "secrets" + if stagedCount == 1 { + stagedPlural = "secret" + } + fmt.Fprintf(out, " * %d %s staged (not yet deployed)\n", stagedCount, stagedPlural) + + partialPlural := "secrets" + if partialCount == 1 { + partialPlural = "secret" + } + fmt.Fprintf(out, " ! %d %s partially deployed (deployed to some machines)\n", partialCount, partialPlural) + + fmt.Fprintf(out, "\nDeploy with `fly secrets deploy` to sync all machines.\n") + } else if stagedCount > 0 { + // Only staged secrets + verb := "are" + plural := "secrets" + if stagedCount == 1 { + verb = "is" + plural = "secret" + } + fmt.Fprintf(out, "There %s %d %s not deployed. Deploy with `fly secrets deploy` to make them available.\n", verb, stagedCount, plural) + } else if partialCount > 0 { + // Only partial secrets + verb := "are" + plural := "secrets" + if partialCount == 1 { + verb = "is" + plural = "secret" + } + fmt.Fprintf(out, "There %s %d %s partially deployed. This can happen during rolling deployments or if some machines failed to update. Deploy with `fly secrets deploy` to ensure all machines have the latest configuration.\n", verb, partialCount, plural) + } +} + +func filterRelevantMachines(machines []*fly.Machine) []*fly.Machine { + if machines == nil { + return nil + } + + relevant := make([]*fly.Machine, 0, len(machines)) + for _, m := range machines { + if m.State != "destroyed" && m.State != "destroying" { + relevant = append(relevant, m) + } } + return relevant +} + +func getMachineReleaseVersion(m *fly.Machine) string { + if m == nil || m.Config == nil || m.Config.Metadata == nil { + return "" + } + return m.Config.Metadata[fly.MachineConfigMetadataKeyFlyReleaseVersion] +} + +type versionInfo struct { + createdAt time.Time + machineCount int +} + +type versionCounts struct { + totalMachines int + versions map[string]versionInfo +} + +func buildVersionCounts(machines []*fly.Machine, releaseTimestamps map[string]time.Time) versionCounts { + result := versionCounts{ + versions: make(map[string]versionInfo), + } + + if machines == nil { + return result + } + + for _, machine := range machines { + result.totalMachines++ + version := getMachineReleaseVersion(machine) + createdAt, ok := releaseTimestamps[version] + if !ok { + continue + } + + if info, exists := result.versions[version]; exists { + info.machineCount++ + result.versions[version] = info + } else { + result.versions[version] = versionInfo{ + createdAt: createdAt, + machineCount: 1, + } + } + } + + return result +} + +func computeSecretStatus(secret fly.AppSecret, vc versionCounts) SecretStatus { + if vc.totalMachines == 0 { + return StatusStaged + } + + // Parse the UpdatedAt timestamp string + secretUpdatedAt := parseTimestamp(secret.UpdatedAt) + if secretUpdatedAt == nil { + return StatusUnknown + } + + // Loop through unique versions instead of all machines + machinesWithSecret := 0 + for _, info := range vc.versions { + if !secretUpdatedAt.After(info.createdAt) { + machinesWithSecret += info.machineCount + } + } + + switch { + case machinesWithSecret == 0: + return StatusStaged + case machinesWithSecret == vc.totalMachines: + return StatusDeployed + default: + return StatusPartiallyDeployed + } +} + +func collectReleaseVersions(machines []*fly.Machine) []string { + if machines == nil { + return nil + } + + versions := make([]string, 0, len(machines)) + for _, machine := range machines { + version := getMachineReleaseVersion(machine) + if version != "" { + versions = append(versions, version) + } + } + return versions +} + +func fetchReleaseTimestamps( + ctx context.Context, + uiexClient uiexutil.Client, + appName string, + releaseVersions []string, +) map[string]time.Time { + timestamps := make(map[string]time.Time) + + if uiexClient == nil || len(releaseVersions) == 0 { + return timestamps + } + + uniqueVersions := make(map[string]struct{}, len(releaseVersions)) + for _, v := range releaseVersions { + if v == "" { + continue + } + uniqueVersions[v] = struct{}{} + } + + if len(uniqueVersions) == 0 { + return timestamps + } + + limit := len(uniqueVersions) * 2 + if limit < 20 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + + releases, err := uiexClient.ListReleases(ctx, appName, limit) + if err != nil { + return timestamps + } + + for _, release := range releases { + versionKey := strconv.Itoa(release.Version) + if _, ok := uniqueVersions[versionKey]; ok { + timestamps[versionKey] = release.CreatedAt + } + } + + return timestamps +} + +// parseTimestamp attempts to parse a timestamp string into a time.Time. +// Returns nil if the string pointer is nil, empty, or cannot be parsed. +func parseTimestamp(value *string) *time.Time { + if value == nil || *value == "" { + return nil + } + + // Try RFC3339 (with or without timezone) + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02T15:04:05.999999", + } + + for _, layout := range layouts { + if ts, err := time.Parse(layout, *value); err == nil { + return &ts + } + } + + return nil } diff --git a/internal/command/secrets/list_test.go b/internal/command/secrets/list_test.go new file mode 100644 index 0000000000..bed00d8c45 --- /dev/null +++ b/internal/command/secrets/list_test.go @@ -0,0 +1,235 @@ +package secrets + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + fly "github.com/superfly/fly-go" +) + +func TestComputeSecretStatus(t *testing.T) { + releaseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + secretUpdated := releaseTime.Add(-time.Hour) + updatedAtStr := secretUpdated.Format(time.RFC3339) + + secret := fly.AppSecret{ + Name: "MY_SECRET", + Digest: "digest", + UpdatedAt: &updatedAtStr, + } + + machines := []*fly.Machine{ + { + ID: "m1", + State: "started", + Config: &fly.MachineConfig{ + Metadata: map[string]string{ + fly.MachineConfigMetadataKeyFlyReleaseVersion: "100", + }, + }, + }, + { + ID: "m2", + State: "started", + Config: &fly.MachineConfig{ + Metadata: map[string]string{ + fly.MachineConfigMetadataKeyFlyReleaseVersion: "100", + }, + }, + }, + } + + releaseTimestamps := map[string]time.Time{ + "100": releaseTime, + } + + vc := buildVersionCounts(machines, releaseTimestamps) + assert.Equal(t, StatusDeployed, computeSecretStatus(secret, vc)) +} + +func TestComputeSecretStatus_NotDeployed(t *testing.T) { + releaseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + secretUpdated := releaseTime.Add(time.Hour) + updatedAtStr := secretUpdated.Format(time.RFC3339) + + secret := fly.AppSecret{ + Name: "MY_SECRET", + Digest: "digest", + UpdatedAt: &updatedAtStr, + } + + machines := []*fly.Machine{ + { + ID: "m1", + State: "started", + Config: &fly.MachineConfig{ + Metadata: map[string]string{ + fly.MachineConfigMetadataKeyFlyReleaseVersion: "100", + }, + }, + }, + } + + releaseTimestamps := map[string]time.Time{ + "100": releaseTime, + } + + vc := buildVersionCounts(machines, releaseTimestamps) + assert.Equal(t, StatusStaged, computeSecretStatus(secret, vc)) +} + +func TestComputeSecretStatus_PartiallyDeployed(t *testing.T) { + releaseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + secretUpdated := releaseTime.Add(-time.Minute) + updatedAtStr := secretUpdated.Format(time.RFC3339) + + secret := fly.AppSecret{ + Name: "MY_SECRET", + Digest: "digest", + UpdatedAt: &updatedAtStr, + } + + machines := []*fly.Machine{ + { + ID: "m1", + State: "started", + Config: &fly.MachineConfig{ + Metadata: map[string]string{ + fly.MachineConfigMetadataKeyFlyReleaseVersion: "100", + }, + }, + }, + { + ID: "m2", + State: "started", + Config: &fly.MachineConfig{ + Metadata: map[string]string{ + fly.MachineConfigMetadataKeyFlyReleaseVersion: "101", + }, + }, + }, + } + + releaseTimestamps := map[string]time.Time{ + "100": releaseTime, + // 101 missing -> treated as not deployed on that machine + } + + vc := buildVersionCounts(machines, releaseTimestamps) + assert.Equal(t, StatusPartiallyDeployed, computeSecretStatus(secret, vc)) +} + +func TestComputeSecretStatus_UnknownScenarios(t *testing.T) { + now := time.Now() + nowStr := now.Format(time.RFC3339) + secret := fly.AppSecret{ + Name: "MY_SECRET", + Digest: "digest", + UpdatedAt: &nowStr, + } + + // empty versionCounts (no machines) -> staged + emptyVC := buildVersionCounts(nil, nil) + assert.Equal(t, StatusStaged, computeSecretStatus(secret, emptyVC)) + + // missing updated_at -> unknown + machineWithRelease := []*fly.Machine{ + { + ID: "m1", + State: "started", + Config: &fly.MachineConfig{ + Metadata: map[string]string{ + fly.MachineConfigMetadataKeyFlyReleaseVersion: "100", + }, + }, + }, + } + releases := map[string]time.Time{"100": now} + vcWithRelease := buildVersionCounts(machineWithRelease, releases) + assert.Equal(t, StatusUnknown, computeSecretStatus(fly.AppSecret{Name: "X", Digest: "d"}, vcWithRelease)) +} + +func TestFilterRelevantMachines(t *testing.T) { + machines := []*fly.Machine{ + {ID: "m1", State: "started"}, + {ID: "m2", State: "stopped"}, + {ID: "m3", State: "destroyed"}, + {ID: "m4", State: "destroying"}, + {ID: "m5", State: "suspended"}, + } + + result := filterRelevantMachines(machines) + + assert.Len(t, result, 3) + + states := make([]string, len(result)) + for i, m := range result { + states[i] = m.State + } + + assert.Contains(t, states, "started") + assert.Contains(t, states, "stopped") + assert.Contains(t, states, "suspended") + assert.NotContains(t, states, "destroyed") + assert.NotContains(t, states, "destroying") +} + +func TestFilterRelevantMachines_NilInput(t *testing.T) { + result := filterRelevantMachines(nil) + assert.Nil(t, result) +} + +func TestGetMachineReleaseVersion(t *testing.T) { + tests := []struct { + name string + machine *fly.Machine + expected string + }{ + { + name: "nil machine", + machine: nil, + expected: "", + }, + { + name: "nil config", + machine: &fly.Machine{ID: "m1", Config: nil}, + expected: "", + }, + { + name: "nil metadata", + machine: &fly.Machine{ + ID: "m1", + Config: &fly.MachineConfig{Metadata: nil}, + }, + expected: "", + }, + { + name: "missing release version", + machine: &fly.Machine{ + ID: "m1", + Config: &fly.MachineConfig{Metadata: map[string]string{}}, + }, + expected: "", + }, + { + name: "has release version", + machine: &fly.Machine{ + ID: "m1", + Config: &fly.MachineConfig{ + Metadata: map[string]string{ + fly.MachineConfigMetadataKeyFlyReleaseVersion: "123", + }, + }, + }, + expected: "123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getMachineReleaseVersion(tt.machine) + assert.Equal(t, tt.expected, result) + }) + } +} From e8f33cbd28ffa8ef8ee204c4906ebaa8963471f0 Mon Sep 17 00:00:00 2001 From: Henrique Mouta Date: Fri, 16 Jan 2026 13:59:44 +0000 Subject: [PATCH 2/3] Update fly-go dependency from v0.2.0 to v0.2.1 in go.mod and go.sum --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 82570a0e72..57dd977d9a 100644 --- a/go.mod +++ b/go.mod @@ -74,7 +74,7 @@ require ( github.com/spf13/pflag v1.0.9 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.11.1 - github.com/superfly/fly-go v0.2.0 + github.com/superfly/fly-go v0.2.1 github.com/superfly/graphql v0.2.6 github.com/superfly/lfsc-go v0.1.1 github.com/superfly/macaroon v0.3.0 diff --git a/go.sum b/go.sum index cb30217183..c52d70ee3a 100644 --- a/go.sum +++ b/go.sum @@ -639,6 +639,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/superfly/fly-go v0.2.0 h1:Kfa8SkKEqiXErZiBG17vzKKlwSL3dDI3umpWiLEPVDg= github.com/superfly/fly-go v0.2.0/go.mod h1:2gCFoNR3iUELADGTJtbBoviMa2jlh2vlPK3cKUajOp8= +github.com/superfly/fly-go v0.2.1 h1:MNctspOX1bka3YHcdfG2NB2cYFPCf/D+YlBevoheS3E= +github.com/superfly/fly-go v0.2.1/go.mod h1:2gCFoNR3iUELADGTJtbBoviMa2jlh2vlPK3cKUajOp8= github.com/superfly/graphql v0.2.6 h1:zppbodNerWecoXEdjkhrqaNaSjGqobhXNlViHFuZzb4= github.com/superfly/graphql v0.2.6/go.mod h1:CVfDl31srm8HnJ9udwLu6hFNUW/P6GUM2dKcG1YQ8jc= github.com/superfly/lfsc-go v0.1.1 h1:dGjLgt81D09cG+aR9lJZIdmonjZSR5zYCi7s54+ZU2Q= From 4354b9f0a73c3b94ef09fbe14d3b7fd1827539eb Mon Sep 17 00:00:00 2001 From: Henrique Mouta Date: Fri, 16 Jan 2026 14:03:12 +0000 Subject: [PATCH 3/3] Run go mod tidy --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index c52d70ee3a..2bd70043fc 100644 --- a/go.sum +++ b/go.sum @@ -637,8 +637,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/superfly/fly-go v0.2.0 h1:Kfa8SkKEqiXErZiBG17vzKKlwSL3dDI3umpWiLEPVDg= -github.com/superfly/fly-go v0.2.0/go.mod h1:2gCFoNR3iUELADGTJtbBoviMa2jlh2vlPK3cKUajOp8= github.com/superfly/fly-go v0.2.1 h1:MNctspOX1bka3YHcdfG2NB2cYFPCf/D+YlBevoheS3E= github.com/superfly/fly-go v0.2.1/go.mod h1:2gCFoNR3iUELADGTJtbBoviMa2jlh2vlPK3cKUajOp8= github.com/superfly/graphql v0.2.6 h1:zppbodNerWecoXEdjkhrqaNaSjGqobhXNlViHFuZzb4=