Skip to content

Commit d69a726

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 d69a726

17 files changed

Lines changed: 5900 additions & 307 deletions

cmd/account/aws-creds-rotate.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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+
force bool
23+
}
24+
25+
// newCmdAWSCredsRotate creates the "aws-creds rotate" subcommand for IAM credential rotation.
26+
func newCmdAWSCredsRotate(streams genericclioptions.IOStreams) *cobra.Command {
27+
ops := &awsCredsRotateOptions{
28+
awsCredsOptions: awsCredsOptions{IOStreams: streams, log: newAWSCredsLogger()},
29+
}
30+
31+
cmd := &cobra.Command{
32+
Use: "rotate -C <cluster-id> --reason <reason> [flags]",
33+
Short: "Rotate AWS IAM credentials for a cluster",
34+
Long: `Rotates AWS IAM credentials for osdManagedAdmin and/or osdCcsAdmin users.
35+
Runs a diagnostic snapshot first, then performs the rotation with
36+
interactive confirmation.
37+
38+
Use --refresh-secrets to only delete and recreate CredentialRequest secrets
39+
without rotating AWS keys or modifying Hive secrets. This is useful when
40+
CCO needs to re-provision secrets with existing credentials.
41+
42+
AWS credentials are obtained via backplane by default, falling back to the
43+
default AWS credential chain (env vars, ~/.aws/config). Use --aws-profile
44+
to specify a named profile, or --aws-use-env to skip backplane and use
45+
environment credentials directly (e.g. after rh-aws-saml-login).
46+
47+
Pre-flight checks (IAM permissions, secret existence) block rotation by
48+
default. Use --force to allow proceeding past errors with explicit YES
49+
confirmation — only when you are certain the errors are benign.`,
50+
Example: ` # Rotate osdManagedAdmin credentials
51+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --managed-admin
52+
53+
# Rotate osdCcsAdmin credentials (CCS clusters only)
54+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --ccs-admin
55+
56+
# Rotate both
57+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --managed-admin --ccs-admin
58+
59+
# Only refresh CredentialRequest secrets (no key rotation)
60+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --refresh-secrets
61+
62+
# Dry-run: preview what would happen
63+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --managed-admin --dry-run
64+
65+
# Using rh-aws-saml-login credentials (no backplane)
66+
kinit $USER@IPA.REDHAT.COM
67+
eval $(rh-aws-saml-login --output env rhcontrol)
68+
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
69+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --managed-admin --aws-use-env
70+
71+
# With staging cluster and production hive
72+
osdctl account aws-creds rotate -C $CLUSTER_ID --reason "$JIRA_TICKET" --managed-admin --hive-ocm-url production`,
73+
DisableAutoGenTag: true,
74+
Run: func(cmd *cobra.Command, args []string) {
75+
cmdutil.CheckErr(ops.validateRotate(cmd, args))
76+
cmdutil.CheckErr(runRotate(cmd.Context(), ops))
77+
},
78+
}
79+
80+
ops.addFlags(cmd)
81+
cmd.Flags().BoolVar(&ops.rotateManagedAdmin, "managed-admin", false, "Rotate osdManagedAdmin credentials")
82+
cmd.Flags().BoolVar(&ops.rotateCcsAdmin, "ccs-admin", false, "Rotate osdCcsAdmin credentials (CCS clusters only)")
83+
cmd.Flags().BoolVar(&ops.refreshSecrets, "refresh-secrets", false, "Only delete and recreate CredentialRequest secrets (no key rotation)")
84+
cmd.Flags().BoolVar(&ops.dryRun, "dry-run", false, "Preview rotation actions without making changes")
85+
cmd.Flags().BoolVar(&ops.force, "force", false, "Allow proceeding past pre-flight errors with YES confirmation. Use only when certain the errors are benign (e.g., known SCP restrictions that won't affect rotation)")
86+
87+
return cmd
88+
}
89+
90+
// validateRotate validates that exactly one rotation mode is selected and flags are not conflicting.
91+
func (o *awsCredsRotateOptions) validateRotate(cmd *cobra.Command, args []string) error {
92+
if err := o.validate(cmd, args); err != nil {
93+
return err
94+
}
95+
if o.refreshSecrets && (o.rotateManagedAdmin || o.rotateCcsAdmin) {
96+
return cmdutil.UsageErrorf(cmd, "--refresh-secrets cannot be combined with --managed-admin or --ccs-admin")
97+
}
98+
if !o.rotateManagedAdmin && !o.rotateCcsAdmin && !o.refreshSecrets {
99+
return cmdutil.UsageErrorf(cmd, "at least one of --managed-admin, --ccs-admin, or --refresh-secrets is required")
100+
}
101+
return nil
102+
}
103+
104+
// confirmStrictYES requires the user to type "YES" exactly to proceed.
105+
func confirmStrictYES(in io.Reader, out io.Writer) bool {
106+
fmt.Fprintf(out, "Type YES to continue: ")
107+
var response string
108+
if _, err := fmt.Fscanln(in, &response); err != nil {
109+
return false
110+
}
111+
return response == "YES"
112+
}
113+
114+
// runRotate orchestrates the full rotation workflow: cluster identification,
115+
// diagnostic snapshot, permission verification, and credential rotation.
116+
func runRotate(ctx context.Context, o *awsCredsRotateOptions) error {
117+
118+
rc, err := o.identifyCluster()
119+
if err != nil {
120+
return err
121+
}
122+
defer rc.ocmConn.Close()
123+
124+
if o.rotateCcsAdmin && !rc.isCCS {
125+
return fmt.Errorf("--ccs-admin specified but cluster is not CCS/BYOC")
126+
}
127+
128+
if o.refreshSecrets {
129+
if err := o.resolveForCRSecrets(ctx, rc); err != nil {
130+
return err
131+
}
132+
report, err := controller.DiagnoseCRSecrets(ctx, rc.hiveClient, rc.managedClient, rc.claimName, rc.account, o.Out)
133+
if err != nil {
134+
return err
135+
}
136+
controller.RenderCredRequestTable(report, o.Out)
137+
return runRefreshSecrets(ctx, o, rc, report)
138+
}
139+
140+
if err := o.resolveCluster(ctx, rc); err != nil {
141+
return err
142+
}
143+
144+
o.log.Info("Running pre-rotation diagnostic snapshot")
145+
input := rc.toCredsInput(o.log, o.Out)
146+
report, err := controller.DiagnoseCredentials(ctx, input)
147+
if err != nil {
148+
return err
149+
}
150+
controller.RenderReport(report, o.Out)
151+
152+
if !report.AllPermissionsOK {
153+
o.log.Warn("IAM permission check detected issues")
154+
red := color.New(color.FgRed).SprintFunc()
155+
fmt.Fprintf(o.Out, "\n%s Pre-flight permission checks detected issues.\n", red("[FAIL]"))
156+
fmt.Fprintln(o.Out, "Proceeding may result in failures during rotation or CR secret recreation.")
157+
if !o.force {
158+
fmt.Fprintln(o.Out, "Use --force to allow proceeding with YES confirmation.")
159+
return fmt.Errorf("pre-flight permission checks failed — use --force to override")
160+
}
161+
fmt.Fprintln(o.Out, "--force specified. Type YES to confirm you want to proceed despite errors.")
162+
if !confirmStrictYES(o.In, o.Out) {
163+
o.log.Info("Operation cancelled by user")
164+
return nil
165+
}
166+
}
167+
168+
if o.dryRun {
169+
o.log.Info("Dry-run mode — no changes will be made")
170+
}
171+
172+
rotateInput := &controller.RotateSecretInput{
173+
AccountCRName: rc.claimName,
174+
Account: rc.account,
175+
OsdManagedAdminUsername: rc.adminUsername,
176+
UpdateManagedAdminCreds: o.rotateManagedAdmin,
177+
UpdateCcsCreds: o.rotateCcsAdmin,
178+
DryRun: o.dryRun,
179+
SkipPermissionCheck: !report.AllPermissionsOK,
180+
AwsClient: rc.awsClient,
181+
HiveKubeClient: rc.hiveClient,
182+
ManagedClusterClient: rc.managedClient,
183+
Report: report,
184+
Log: o.log,
185+
In: o.In,
186+
Out: o.Out,
187+
}
188+
189+
if !o.dryRun {
190+
o.log.Warn("Credential rotation will modify IAM keys and Hive secrets")
191+
fmt.Fprintln(o.Out, "\nProceed with credential rotation?")
192+
if !utils.ConfirmPrompt() {
193+
o.log.Info("Rotation cancelled by user")
194+
return nil
195+
}
196+
}
197+
198+
o.log.Info("Starting credential rotation")
199+
if err := controller.RotateSecret(ctx, rotateInput); err != nil {
200+
o.log.WithError(err).Error("Credential rotation failed")
201+
return err
202+
}
203+
o.log.Info("Credential rotation completed successfully")
204+
return nil
205+
}
206+
207+
// runRefreshSecrets deletes and recreates CredentialRequest secrets without rotating AWS keys.
208+
func runRefreshSecrets(ctx context.Context, o *awsCredsRotateOptions, rc *resolvedCluster, report *controller.DiagnosticReport) error {
209+
210+
if rc.managedClient == nil {
211+
return fmt.Errorf("managed cluster client not available — cannot refresh secrets")
212+
}
213+
214+
if report.ClusterRootKeyID == "" {
215+
return fmt.Errorf("cannot refresh CredentialRequest secrets: kube-system/aws-creds is missing or unreadable on the managed cluster — CCO needs this secret to recreate operator credentials")
216+
}
217+
218+
if o.dryRun {
219+
o.log.Info("Dry-run mode — no secrets will be deleted")
220+
fmt.Fprintln(o.Out, "\n[Dry Run] Would delete and recreate all CredentialRequest secrets.")
221+
fmt.Fprintln(o.Out, "[Dry Run] No AWS keys or Hive secrets would be modified.")
222+
return nil
223+
}
224+
225+
fmt.Fprintf(o.Out, "\nThis will delete %d CredentialRequest secret(s) so CCO recreates them.\n", len(report.CredRequests))
226+
fmt.Fprintln(o.Out, "No AWS keys or Hive secrets will be modified.")
227+
fmt.Fprintln(o.Out, "\nProceed with secret refresh?")
228+
if !utils.ConfirmPrompt() {
229+
o.log.Info("Refresh cancelled by user")
230+
return nil
231+
}
232+
233+
o.log.Info("Deleting credential secrets for CCO to recreate")
234+
if err := controller.DeleteCredentialSecrets(ctx, rc.managedClient, o.In, o.Out); err != nil {
235+
o.log.WithError(err).Error("Failed to refresh credential secrets")
236+
return err
237+
}
238+
239+
o.log.Info("Credential secret refresh completed successfully")
240+
return nil
241+
}

cmd/account/aws-creds-snapshot.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
// newCmdAWSCredsSnapshot creates the "aws-creds snapshot" subcommand for read-only credential diagnostics.
18+
func newCmdAWSCredsSnapshot(streams genericclioptions.IOStreams) *cobra.Command {
19+
ops := &awsCredsSnapshotOptions{
20+
awsCredsOptions: awsCredsOptions{IOStreams: streams, log: newAWSCredsLogger()},
21+
}
22+
23+
cmd := &cobra.Command{
24+
Use: "snapshot -C <cluster-id> --reason <reason> [flags]",
25+
Short: "Show a read-only credential status report for a cluster",
26+
Long: `Produces a diagnostic report of AWS IAM credentials including:
27+
- IAM access keys and which Hive secrets reference them
28+
- CredentialRequest secrets and whether they need refresh
29+
- IAM permission simulation (SCP/policy restriction detection)
30+
31+
Use --cr-secrets to show only the CredentialRequest secrets table.
32+
33+
This is a read-only operation — no credentials are modified.
34+
35+
AWS credentials are obtained via backplane by default, falling back to the
36+
default AWS credential chain (env vars, ~/.aws/config). Use --aws-profile
37+
to specify a named profile, or --aws-use-env to skip backplane and use
38+
environment credentials directly (e.g. after rh-aws-saml-login).`,
39+
Example: ` # Full credential status report (uses backplane)
40+
osdctl account aws-creds snapshot -C $CLUSTER_ID --reason "$JIRA_TICKET"
41+
42+
# Only show CredentialRequest secret status
43+
osdctl account aws-creds snapshot -C $CLUSTER_ID --reason "$JIRA_TICKET" --cr-secrets
44+
45+
# Using rh-aws-saml-login credentials (no backplane)
46+
kinit $USER@IPA.REDHAT.COM
47+
eval $(rh-aws-saml-login --output env rhcontrol)
48+
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
49+
osdctl account aws-creds snapshot -C $CLUSTER_ID --reason "$JIRA_TICKET" --aws-use-env
50+
51+
# With staging cluster and production hive
52+
osdctl account aws-creds snapshot -C $CLUSTER_ID --reason "$JIRA_TICKET" --hive-ocm-url production`,
53+
DisableAutoGenTag: true,
54+
Run: func(cmd *cobra.Command, args []string) {
55+
cmdutil.CheckErr(ops.validate(cmd, args))
56+
cmdutil.CheckErr(runSnapshot(cmd.Context(), ops))
57+
},
58+
}
59+
60+
ops.addFlags(cmd)
61+
cmd.Flags().BoolVar(&ops.crSecretsOnly, "cr-secrets", false, "Only show CredentialRequest secrets status")
62+
return cmd
63+
}
64+
65+
// runSnapshot produces the diagnostic report, either full or CR-secrets-only based on flags.
66+
func runSnapshot(ctx context.Context, o *awsCredsSnapshotOptions) error {
67+
68+
rc, err := o.identifyCluster()
69+
if err != nil {
70+
return err
71+
}
72+
defer rc.ocmConn.Close()
73+
74+
if o.crSecretsOnly {
75+
if err := o.resolveForCRSecrets(ctx, rc); err != nil {
76+
return err
77+
}
78+
report, err := controller.DiagnoseCRSecrets(ctx, rc.hiveClient, rc.managedClient, rc.claimName, rc.account, o.Out)
79+
if err != nil {
80+
return err
81+
}
82+
controller.RenderCredRequestTable(report, o.Out)
83+
return nil
84+
}
85+
86+
if err := o.resolveCluster(ctx, rc); err != nil {
87+
return err
88+
}
89+
90+
input := rc.toCredsInput(o.log, o.Out)
91+
report, err := controller.DiagnoseCredentials(ctx, input)
92+
if err != nil {
93+
return err
94+
}
95+
controller.RenderReport(report, o.Out)
96+
return nil
97+
}

0 commit comments

Comments
 (0)