Skip to content

Commit 0d45d32

Browse files
nephomaniacclaude
andcommitted
ROSAENG-2066: Add osdctl account aws-creds command
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5e39b39 commit 0d45d32

15 files changed

Lines changed: 4823 additions & 253 deletions

cmd/account/aws-creds-rotate.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package account
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
8+
"github.com/fatih/color"
9+
"github.com/openshift/osdctl/pkg/controller"
10+
"github.com/openshift/osdctl/pkg/utils"
11+
"github.com/spf13/cobra"
12+
"k8s.io/cli-runtime/pkg/genericclioptions"
13+
cmdutil "k8s.io/kubectl/pkg/cmd/util"
14+
)
15+
16+
type awsCredsRotateOptions struct {
17+
awsCredsOptions
18+
rotateManagedAdmin bool
19+
rotateCcsAdmin bool
20+
refreshSecrets bool
21+
dryRun bool
22+
}
23+
24+
func newCmdAWSCredsRotate(streams genericclioptions.IOStreams) *cobra.Command {
25+
ops := &awsCredsRotateOptions{
26+
awsCredsOptions: awsCredsOptions{IOStreams: streams, log: newAWSCredsLogger()},
27+
}
28+
29+
cmd := &cobra.Command{
30+
Use: "rotate -C <cluster-id> --reason <reason> [flags]",
31+
Short: "Rotate AWS IAM credentials for a cluster",
32+
Long: `Rotates AWS IAM credentials for osdManagedAdmin and/or osdCcsAdmin users.
33+
Runs a diagnostic snapshot first, then performs the rotation with
34+
interactive confirmation.
35+
36+
Use --refresh-secrets to only delete and recreate CredentialRequest secrets
37+
without rotating AWS keys or modifying Hive secrets. This is useful when
38+
CCO needs to re-provision secrets with existing credentials.
39+
40+
AWS credentials are obtained via backplane by default. Use --aws-profile
41+
to override with a local AWS profile and manual role chaining.`,
42+
Example: ` # Rotate osdManagedAdmin credentials
43+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --managed-admin
44+
45+
# Rotate osdCcsAdmin credentials (CCS clusters only)
46+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --ccs-admin
47+
48+
# Rotate both
49+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --managed-admin --ccs-admin
50+
51+
# Only refresh CredentialRequest secrets (no key rotation)
52+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --refresh-secrets
53+
54+
# Dry-run: preview what would happen
55+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --managed-admin --dry-run
56+
57+
# With staging cluster and production hive
58+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --managed-admin --hive-ocm-url production`,
59+
DisableAutoGenTag: true,
60+
Run: func(cmd *cobra.Command, args []string) {
61+
cmdutil.CheckErr(ops.validateRotate(cmd, args))
62+
cmdutil.CheckErr(runRotate(cmd.Context(), ops))
63+
},
64+
}
65+
66+
ops.addFlags(cmd)
67+
cmd.Flags().BoolVar(&ops.rotateManagedAdmin, "managed-admin", false, "Rotate osdManagedAdmin credentials")
68+
cmd.Flags().BoolVar(&ops.rotateCcsAdmin, "ccs-admin", false, "Rotate osdCcsAdmin credentials (CCS clusters only)")
69+
cmd.Flags().BoolVar(&ops.refreshSecrets, "refresh-secrets", false, "Only delete and recreate CredentialRequest secrets (no key rotation)")
70+
cmd.Flags().BoolVar(&ops.dryRun, "dry-run", false, "Preview rotation actions without making changes")
71+
72+
return cmd
73+
}
74+
75+
func (o *awsCredsRotateOptions) validateRotate(cmd *cobra.Command, args []string) error {
76+
if err := o.validate(cmd, args); err != nil {
77+
return err
78+
}
79+
if o.refreshSecrets && (o.rotateManagedAdmin || o.rotateCcsAdmin) {
80+
return cmdutil.UsageErrorf(cmd, "--refresh-secrets cannot be combined with --managed-admin or --ccs-admin")
81+
}
82+
if !o.rotateManagedAdmin && !o.rotateCcsAdmin && !o.refreshSecrets {
83+
return cmdutil.UsageErrorf(cmd, "at least one of --managed-admin, --ccs-admin, or --refresh-secrets is required")
84+
}
85+
return nil
86+
}
87+
88+
// confirmStrictYES requires the user to type "YES" exactly to proceed.
89+
func confirmStrictYES(in io.Reader, out io.Writer) bool {
90+
fmt.Fprintf(out, "Type YES to continue: ")
91+
var response string
92+
if _, err := fmt.Fscanln(in, &response); err != nil {
93+
return false
94+
}
95+
return response == "YES"
96+
}
97+
98+
func runRotate(ctx context.Context, o *awsCredsRotateOptions) error {
99+
100+
rc, err := o.identifyCluster()
101+
if err != nil {
102+
return err
103+
}
104+
defer rc.ocmConn.Close()
105+
106+
if o.rotateCcsAdmin && !rc.isCCS {
107+
return fmt.Errorf("--ccs-admin specified but cluster is not CCS/BYOC")
108+
}
109+
110+
if o.refreshSecrets {
111+
if err := o.resolveForCRSecrets(ctx, rc); err != nil {
112+
return err
113+
}
114+
report, err := controller.DiagnoseCRSecrets(ctx, rc.hiveClient, rc.managedClient, rc.claimName, rc.account, o.Out)
115+
if err != nil {
116+
return err
117+
}
118+
controller.RenderCredRequestTable(report, o.Out)
119+
return runRefreshSecrets(ctx, o, rc, report)
120+
}
121+
122+
if err := o.resolveCluster(ctx, rc); err != nil {
123+
return err
124+
}
125+
126+
o.log.Info("Running pre-rotation diagnostic snapshot")
127+
input := rc.toCredsInput(o.log, o.Out)
128+
report, err := controller.DiagnoseCredentials(ctx, input)
129+
if err != nil {
130+
return err
131+
}
132+
controller.RenderReport(report, o.Out)
133+
134+
if !report.AllPermissionsOK {
135+
o.log.Warn("IAM permission check detected issues")
136+
red := color.New(color.FgRed).SprintFunc()
137+
fmt.Fprintf(o.Out, "\n%s Pre-flight permission checks detected issues.\n", red("[WARN]"))
138+
fmt.Fprintln(o.Out, "Proceeding may result in failures during rotation or CR secret recreation.")
139+
if !confirmStrictYES(o.In, o.Out) {
140+
o.log.Info("Operation cancelled by user")
141+
return nil
142+
}
143+
}
144+
145+
if o.dryRun {
146+
o.log.Info("Dry-run mode — no changes will be made")
147+
}
148+
149+
rotateInput := &controller.RotateSecretInput{
150+
AccountCRName: rc.claimName,
151+
Account: rc.account,
152+
OsdManagedAdminUsername: rc.adminUsername,
153+
UpdateManagedAdminCreds: o.rotateManagedAdmin,
154+
UpdateCcsCreds: o.rotateCcsAdmin,
155+
DryRun: o.dryRun,
156+
SkipPermissionCheck: !report.AllPermissionsOK,
157+
AwsClient: rc.awsClient,
158+
HiveKubeClient: rc.hiveClient,
159+
ManagedClusterClient: rc.managedClient,
160+
Report: report,
161+
Log: o.log,
162+
In: o.In,
163+
Out: o.Out,
164+
}
165+
166+
if !o.dryRun {
167+
o.log.Warn("Credential rotation will modify IAM keys and Hive secrets")
168+
fmt.Fprintln(o.Out, "\nProceed with credential rotation?")
169+
if !utils.ConfirmPrompt() {
170+
o.log.Info("Rotation cancelled by user")
171+
return nil
172+
}
173+
}
174+
175+
o.log.Info("Starting credential rotation")
176+
if err := controller.RotateSecret(ctx, rotateInput); err != nil {
177+
o.log.WithError(err).Error("Credential rotation failed")
178+
return err
179+
}
180+
o.log.Info("Credential rotation completed successfully")
181+
return nil
182+
}
183+
184+
func runRefreshSecrets(ctx context.Context, o *awsCredsRotateOptions, rc *resolvedCluster, report *controller.DiagnosticReport) error {
185+
186+
if rc.managedClient == nil {
187+
return fmt.Errorf("managed cluster client not available — cannot refresh secrets")
188+
}
189+
190+
if o.dryRun {
191+
o.log.Info("Dry-run mode — no secrets will be deleted")
192+
fmt.Fprintln(o.Out, "\n[Dry Run] Would delete and recreate all CredentialRequest secrets.")
193+
fmt.Fprintln(o.Out, "[Dry Run] No AWS keys or Hive secrets would be modified.")
194+
return nil
195+
}
196+
197+
fmt.Fprintf(o.Out, "\nThis will delete %d CredentialRequest secret(s) so CCO recreates them.\n", len(report.CredRequests))
198+
fmt.Fprintln(o.Out, "No AWS keys or Hive secrets will be modified.")
199+
fmt.Fprintln(o.Out, "\nProceed with secret refresh?")
200+
if !utils.ConfirmPrompt() {
201+
o.log.Info("Refresh cancelled by user")
202+
return nil
203+
}
204+
205+
o.log.Info("Deleting credential secrets for CCO to recreate")
206+
if err := controller.DeleteCredentialSecrets(ctx, rc.managedClient, o.Out); err != nil {
207+
o.log.WithError(err).Error("Failed to refresh credential secrets")
208+
return err
209+
}
210+
211+
o.log.Info("Credential secret refresh completed successfully")
212+
return nil
213+
}

cmd/account/aws-creds-snapshot.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package account
2+
3+
import (
4+
"context"
5+
6+
"github.com/openshift/osdctl/pkg/controller"
7+
"github.com/spf13/cobra"
8+
"k8s.io/cli-runtime/pkg/genericclioptions"
9+
cmdutil "k8s.io/kubectl/pkg/cmd/util"
10+
)
11+
12+
type awsCredsSnapshotOptions struct {
13+
awsCredsOptions
14+
crSecretsOnly bool
15+
}
16+
17+
func newCmdAWSCredsSnapshot(streams genericclioptions.IOStreams) *cobra.Command {
18+
ops := &awsCredsSnapshotOptions{
19+
awsCredsOptions: awsCredsOptions{IOStreams: streams, log: newAWSCredsLogger()},
20+
}
21+
22+
cmd := &cobra.Command{
23+
Use: "snapshot -C <cluster-id> --reason <reason> [flags]",
24+
Short: "Show a read-only credential status report for a cluster",
25+
Long: `Produces a diagnostic report of AWS IAM credentials including:
26+
- IAM access keys and which Hive secrets reference them
27+
- CredentialRequest secrets and whether they need refresh
28+
- IAM permission simulation (SCP/policy restriction detection)
29+
30+
Use --cr-secrets to show only the CredentialRequest secrets table.
31+
32+
This is a read-only operation — no credentials are modified.
33+
34+
AWS credentials are obtained via backplane by default. Use --aws-profile
35+
to override with a local AWS profile and manual role chaining.`,
36+
Example: ` # Full credential status report
37+
osdctl account aws-creds snapshot -C $CLUSTER_ID --reason "$JIRA_TICKET"
38+
39+
# Only show CredentialRequest secret status
40+
osdctl account aws-creds snapshot -C $CLUSTER_ID --reason "$JIRA_TICKET" --cr-secrets
41+
42+
# With staging cluster and production hive
43+
osdctl account aws-creds snapshot -C $CLUSTER_ID --reason "$JIRA_TICKET" --hive-ocm-url production`,
44+
DisableAutoGenTag: true,
45+
Run: func(cmd *cobra.Command, args []string) {
46+
cmdutil.CheckErr(ops.validate(cmd, args))
47+
cmdutil.CheckErr(runSnapshot(cmd.Context(), ops))
48+
},
49+
}
50+
51+
ops.addFlags(cmd)
52+
cmd.Flags().BoolVar(&ops.crSecretsOnly, "cr-secrets", false, "Only show CredentialRequest secrets status")
53+
return cmd
54+
}
55+
56+
func runSnapshot(ctx context.Context, o *awsCredsSnapshotOptions) error {
57+
58+
rc, err := o.identifyCluster()
59+
if err != nil {
60+
return err
61+
}
62+
defer rc.ocmConn.Close()
63+
64+
if o.crSecretsOnly {
65+
if err := o.resolveForCRSecrets(ctx, rc); err != nil {
66+
return err
67+
}
68+
report, err := controller.DiagnoseCRSecrets(ctx, rc.hiveClient, rc.managedClient, rc.claimName, rc.account, o.Out)
69+
if err != nil {
70+
return err
71+
}
72+
controller.RenderCredRequestTable(report, o.Out)
73+
return nil
74+
}
75+
76+
if err := o.resolveCluster(ctx, rc); err != nil {
77+
return err
78+
}
79+
80+
input := rc.toCredsInput(o.log, o.Out)
81+
report, err := controller.DiagnoseCredentials(ctx, input)
82+
if err != nil {
83+
return err
84+
}
85+
controller.RenderReport(report, o.Out)
86+
return nil
87+
}

0 commit comments

Comments
 (0)