From 8e26008a90c113e68549f4c514d339da0adeafc0 Mon Sep 17 00:00:00 2001 From: ditahkk Date: Thu, 6 Nov 2025 12:00:41 -0500 Subject: [PATCH 1/6] Enhance power operation handling and validation --- ztictl/cmd/ztictl/ssm_power.go | 57 +++++-- ztictl/cmd/ztictl/ssm_power_test.go | 238 +++++++++++++++++++++++++++- ztictl/go.mod | 45 +++--- ztictl/go.sum | 90 ++++++----- ztictl/internal/ssm/manager.go | 36 +++-- ztictl/internal/ssm/manager_test.go | 76 +++++++++ 6 files changed, 439 insertions(+), 103 deletions(-) diff --git a/ztictl/cmd/ztictl/ssm_power.go b/ztictl/cmd/ztictl/ssm_power.go index ddcc05c..5ff1191 100644 --- a/ztictl/cmd/ztictl/ssm_power.go +++ b/ztictl/cmd/ztictl/ssm_power.go @@ -170,7 +170,7 @@ Examples: // Execute power operations in parallel startTime := time.Now() - results := executePowerOperationParallel(ctx, awsClient, instanceIDs, "start", parallelFlag) + results := executePowerOperationParallel(ctx, awsClient, instanceIDs, "start", parallelFlag, region) totalDuration := time.Since(startTime) // Process and display results @@ -248,7 +248,7 @@ Examples: // Execute power operations in parallel startTime := time.Now() - results := executePowerOperationParallel(ctx, awsClient, instanceIDs, "stop", parallelFlag) + results := executePowerOperationParallel(ctx, awsClient, instanceIDs, "stop", parallelFlag, region) totalDuration := time.Since(startTime) // Process and display results @@ -326,7 +326,7 @@ Examples: // Execute power operations in parallel startTime := time.Now() - results := executePowerOperationParallel(ctx, awsClient, instanceIDs, "reboot", parallelFlag) + results := executePowerOperationParallel(ctx, awsClient, instanceIDs, "reboot", parallelFlag, region) totalDuration := time.Since(startTime) // Process and display results @@ -368,7 +368,7 @@ func performPowerOperation(args []string, regionCode, instancesFlag string, para } startTime := time.Now() - results := executePowerOperationParallel(ctx, awsClient, instanceIDs, operation, parallelFlag) + results := executePowerOperationParallel(ctx, awsClient, instanceIDs, operation, parallelFlag, region) totalDuration := time.Since(startTime) return displayPowerOperationResults(results, operation, totalDuration, parallelFlag) } @@ -509,7 +509,9 @@ func getInstanceIDsByTags(ctx context.Context, awsClient *aws.Client, tagsFlag s } // executePowerOperationParallel runs power operations in parallel across multiple instances -func executePowerOperationParallel(ctx context.Context, awsClient *aws.Client, instanceIDs []string, operation string, maxParallel int) []PowerOperationResult { +func executePowerOperationParallel(ctx context.Context, awsClient *aws.Client, instanceIDs []string, operation string, maxParallel int, region string) []PowerOperationResult { + ssmManager := ssm.NewManager(logger) + // Create channels for work distribution and result collection instanceChan := make(chan string, len(instanceIDs)) resultChan := make(chan PowerOperationResult, len(instanceIDs)) @@ -531,23 +533,44 @@ func executePowerOperationParallel(ctx context.Context, awsClient *aws.Client, i logging.LogInfo("Executing %s operation on instance %s", operation, instanceID) var err error + + // Validate instance state before attempting operation + requirements := InstanceValidationRequirements{ + RequireSSMOnline: false, + Operation: operation, + } switch operation { case "start": - _, err = awsClient.EC2.StartInstances(ctx, &ec2.StartInstancesInput{ - InstanceIds: []string{instanceID}, - }) - case "stop": - _, err = awsClient.EC2.StopInstances(ctx, &ec2.StopInstancesInput{ - InstanceIds: []string{instanceID}, - }) - case "reboot": - _, err = awsClient.EC2.RebootInstances(ctx, &ec2.RebootInstancesInput{ - InstanceIds: []string{instanceID}, - }) + requirements.AllowedStates = []string{"stopped"} + case "stop", "reboot": + requirements.AllowedStates = []string{"running"} default: err = fmt.Errorf("unknown operation: %s", operation) } + // Only proceed with validation and operation if no error yet + if err == nil { + err = ValidateInstanceState(ctx, ssmManager, instanceID, region, requirements) + } + + // Execute power operation only if validation passed + if err == nil { + switch operation { + case "start": + _, err = awsClient.EC2.StartInstances(ctx, &ec2.StartInstancesInput{ + InstanceIds: []string{instanceID}, + }) + case "stop": + _, err = awsClient.EC2.StopInstances(ctx, &ec2.StopInstancesInput{ + InstanceIds: []string{instanceID}, + }) + case "reboot": + _, err = awsClient.EC2.RebootInstances(ctx, &ec2.RebootInstancesInput{ + InstanceIds: []string{instanceID}, + }) + } + } + duration := time.Since(startTime) resultChan <- PowerOperationResult{ @@ -567,7 +590,7 @@ func executePowerOperationParallel(ctx context.Context, awsClient *aws.Client, i }() // Collect all results - var results []PowerOperationResult + results := make([]PowerOperationResult, 0, len(instanceIDs)) for result := range resultChan { results = append(results, result) } diff --git a/ztictl/cmd/ztictl/ssm_power_test.go b/ztictl/cmd/ztictl/ssm_power_test.go index f1e7788..5ee3fa4 100644 --- a/ztictl/cmd/ztictl/ssm_power_test.go +++ b/ztictl/cmd/ztictl/ssm_power_test.go @@ -689,10 +689,8 @@ func TestValidateTaggedCommandArgs(t *testing.T) { if tt.errorContains != "" && err.Error() != tt.errorContains { t.Errorf("Expected error containing %q, got %q", tt.errorContains, err.Error()) } - } else { - if err != nil { - t.Errorf("Expected no error but got: %v", err) - } + } else if err != nil { + t.Errorf("Expected no error but got: %v", err) } }) } @@ -843,10 +841,8 @@ func TestDisplayPowerOperationResults(t *testing.T) { if tt.errorContains != "" && err.Error() != tt.errorContains { t.Errorf("Expected error containing %q, got %q", tt.errorContains, err.Error()) } - } else { - if err != nil { - t.Errorf("Expected no error but got: %v", err) - } + } else if err != nil { + t.Errorf("Expected no error but got: %v", err) } }) } @@ -860,3 +856,229 @@ type stubError struct { func (e *stubError) Error() string { return e.message } + +func TestStateValidationRequirements(t *testing.T) { + tests := []struct { + name string + operation string + expectedStates []string + requireSSMOnline bool + }{ + { + name: "start operation requires stopped state", + operation: "start", + expectedStates: []string{"stopped"}, + requireSSMOnline: false, + }, + { + name: "stop operation requires running state", + operation: "stop", + expectedStates: []string{"running"}, + requireSSMOnline: false, + }, + { + name: "reboot operation requires running state", + operation: "reboot", + expectedStates: []string{"running"}, + requireSSMOnline: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requirements InstanceValidationRequirements + requirements.RequireSSMOnline = false + requirements.Operation = tt.operation + + switch tt.operation { + case "start": + requirements.AllowedStates = []string{"stopped"} + case "stop", "reboot": + requirements.AllowedStates = []string{"running"} + } + + if requirements.Operation != tt.operation { + t.Errorf("Expected operation %q, got %q", tt.operation, requirements.Operation) + } + + if requirements.RequireSSMOnline != tt.requireSSMOnline { + t.Errorf("Expected RequireSSMOnline %v, got %v", tt.requireSSMOnline, requirements.RequireSSMOnline) + } + + if len(requirements.AllowedStates) != len(tt.expectedStates) { + t.Errorf("Expected %d allowed states, got %d", len(tt.expectedStates), len(requirements.AllowedStates)) + } + + for i, state := range tt.expectedStates { + if i >= len(requirements.AllowedStates) || requirements.AllowedStates[i] != state { + t.Errorf("Expected state %q at index %d, got %q", state, i, requirements.AllowedStates[i]) + } + } + }) + } +} + +func TestPowerOperationResultErrorHandling(t *testing.T) { + tests := []struct { + name string + results []PowerOperationResult + expectError bool + description string + }{ + { + name: "state validation error for start on running instance", + results: []PowerOperationResult{ + { + InstanceID: "i-1234567890abcdef0", + Operation: "start", + Error: errors.New("instance is in 'running' state, expected one of: [stopped]"), + Duration: time.Millisecond * 100, + }, + }, + expectError: true, + description: "Should fail when trying to start a running instance", + }, + { + name: "state validation error for stop on stopped instance", + results: []PowerOperationResult{ + { + InstanceID: "i-1234567890abcdef0", + Operation: "stop", + Error: errors.New("instance is in 'stopped' state, expected one of: [running]"), + Duration: time.Millisecond * 100, + }, + }, + expectError: true, + description: "Should fail when trying to stop a stopped instance", + }, + { + name: "state validation error for reboot on stopped instance", + results: []PowerOperationResult{ + { + InstanceID: "i-1234567890abcdef0", + Operation: "reboot", + Error: errors.New("instance is in 'stopped' state, expected one of: [running]"), + Duration: time.Millisecond * 100, + }, + }, + expectError: true, + description: "Should fail when trying to reboot a stopped instance", + }, + { + name: "successful operation with no errors", + results: []PowerOperationResult{ + { + InstanceID: "i-1234567890abcdef0", + Operation: "start", + Error: nil, + Duration: time.Millisecond * 200, + }, + }, + expectError: false, + description: "Should succeed when instance is in correct state", + }, + { + name: "mixed results with some validation failures", + results: []PowerOperationResult{ + { + InstanceID: "i-1111111111111111", + Operation: "start", + Error: nil, + Duration: time.Millisecond * 150, + }, + { + InstanceID: "i-2222222222222222", + Operation: "start", + Error: errors.New("instance is in 'running' state, expected one of: [stopped]"), + Duration: time.Millisecond * 100, + }, + { + InstanceID: "i-3333333333333333", + Operation: "start", + Error: errors.New("instance is in 'running' state, expected one of: [stopped]"), + Duration: time.Millisecond * 100, + }, + }, + expectError: true, + description: "Should report partial failure when some instances fail validation", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := displayPowerOperationResults(tt.results, tt.results[0].Operation, time.Second, 4) + + if tt.expectError && err == nil { + t.Errorf("%s: expected error but got nil", tt.description) + } + + if !tt.expectError && err != nil { + t.Errorf("%s: expected no error but got: %v", tt.description, err) + } + + successCount := 0 + for _, result := range tt.results { + if result.Error == nil { + successCount++ + } + } + + if tt.expectError && successCount == len(tt.results) { + t.Errorf("%s: all operations succeeded but expected failures", tt.description) + } + }) + } +} + +func TestGetInstanceIDsByTagsFormatting(t *testing.T) { + tests := []struct { + name string + tagsFlag string + expected int + desc string + }{ + { + name: "single tag", + tagsFlag: "Environment=production", + expected: 1, + desc: "Should parse single tag correctly", + }, + { + name: "multiple tags", + tagsFlag: "Environment=production,Team=backend", + expected: 2, + desc: "Should parse multiple tags correctly", + }, + { + name: "tags with spaces", + tagsFlag: "Environment=production, Team=backend", + expected: 2, + desc: "Should handle spaces in tag list", + }, + { + name: "empty tag string", + tagsFlag: "", + expected: 0, + desc: "Should handle empty tag string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filterCount := 0 + if tt.tagsFlag != "" { + tagPairs := strings.Split(tt.tagsFlag, ",") + for _, pair := range tagPairs { + parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(parts) == 2 { + filterCount++ + } + } + } + + if filterCount != tt.expected { + t.Errorf("%s: expected %d filters, got %d", tt.desc, tt.expected, filterCount) + } + }) + } +} diff --git a/ztictl/go.mod b/ztictl/go.mod index 392989d..d32ecba 100644 --- a/ztictl/go.mod +++ b/ztictl/go.mod @@ -5,15 +5,15 @@ go 1.24.5 replace github.com/ktr0731/go-fuzzyfinder => github.com/zsoftly/go-fuzzyfinder v0.0.0-20251011215817-623ae668e846 require ( - github.com/aws/aws-sdk-go-v2 v1.39.2 - github.com/aws/aws-sdk-go-v2/config v1.31.12 - github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.1 - github.com/aws/aws-sdk-go-v2/service/iam v1.47.7 - github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 - github.com/aws/aws-sdk-go-v2/service/ssm v1.65.1 - github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 - github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 + github.com/aws/aws-sdk-go-v2 v1.39.6 + github.com/aws/aws-sdk-go-v2/config v1.31.17 + github.com/aws/aws-sdk-go-v2/service/ec2 v1.262.0 + github.com/aws/aws-sdk-go-v2/service/iam v1.49.2 + github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 + github.com/aws/aws-sdk-go-v2/service/ssm v1.66.4 + github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 github.com/fatih/color v1.18.0 github.com/ktr0731/go-fuzzyfinder v0.9.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -26,19 +26,20 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.16 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 // indirect - github.com/aws/smithy-go v1.23.0 // indirect - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect + github.com/aws/smithy-go v1.23.2 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect diff --git a/ztictl/go.sum b/ztictl/go.sum index 61e87f5..3d78ff9 100644 --- a/ztictl/go.sum +++ b/ztictl/go.sum @@ -1,49 +1,51 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I= -github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00= -github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8= -github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8= -github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9 h1:w9LnHqTq8MEdlnyhV4Bwfizd65lfNCNgdlNC6mM5paE= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9/go.mod h1:LGEP6EK4nj+bwWNdrvX/FnDTFowdBNwcSPuZu/ouFys= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.1 h1:7p9bJCZ/b3EJXXARW7JMEs2IhsnI4YFHpfXQfgMh0eg= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.1/go.mod h1:M8WWWIfXmxA4RgTXcI/5cSByxRqjgne32Sh0VIbrn0A= -github.com/aws/aws-sdk-go-v2/service/iam v1.47.7 h1:0EDAdmMTzsgXl++8a0JZ+Yx0/dOqT8o/EONknxlQK94= -github.com/aws/aws-sdk-go-v2/service/iam v1.47.7/go.mod h1:NkNbn/8/mFrPUq0Kg6EM6c0+GaTLG+aPzXxwB7RF5xo= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0 h1:X0FveUndcZ3lKbSpIC6rMYGRiQTcUVRNH6X4yYtIrlU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0/go.mod h1:IWjQYlqw4EX9jw2g3qnEPPWvCE6bS8fKzhMed1OK7c8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 h1:wuZ5uW2uhJR63zwNlqWH2W4aL4ZjeJP3o92/W+odDY4= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9/go.mod h1:/G58M2fGszCrOzvJUkDdY8O9kycodunH4VdT5oBAqls= -github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 h1:mUI3b885qJgfqKDUSj6RgbRqLdX0wGmg8ruM03zNfQA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4/go.mod h1:6v8ukAxc7z4x4oBjGUsLnH7KGLY9Uhcgij19UJNkiMg= -github.com/aws/aws-sdk-go-v2/service/ssm v1.65.1 h1:TFg6XiS7EsHN0/jpV3eVNczZi/sPIVP5jxIs+euIESQ= -github.com/aws/aws-sdk-go-v2/service/ssm v1.65.1/go.mod h1:OIezd9K0sM/64DDP4kXx/i0NdgXu6R5KE6SCsIPJsjc= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8= -github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= -github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk= +github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.262.0 h1:5qBb1XV/D18qtCHd3bmmxoVglI+fZ4QWuS/EB8kIXYQ= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.262.0/go.mod h1:NDdDLLW5PtLLXN661gKcvJvqAH5OBXsfhMlmKVu1/pY= +github.com/aws/aws-sdk-go-v2/service/iam v1.49.2 h1:XeF6yEMX4/FxoSHCE1VNMOZ0t+mGnf/onqVe9dDVAlQ= +github.com/aws/aws-sdk-go-v2/service/iam v1.49.2/go.mod h1:cuEMbL1mNtO1sUyT+DYDNIA8Y7aJG1oIdgHqUk29Uzk= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 h1:NvMjwvv8hpGUILarKw7Z4Q0w1H9anXKsesMxtw++MA4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4/go.mod h1:455WPHSwaGj2waRSpQp7TsnpOnBfw8iDfPfbwl7KPJE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 h1:ef6gIJR+xv/JQWwpa5FYirzoQctfSJm7tuDe3SZsUf8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw= +github.com/aws/aws-sdk-go-v2/service/ssm v1.66.4 h1:UmkF0ipNy0Ps6csJl/ZRJ3K+DWe9q0A7LT3xfxoHbgg= +github.com/aws/aws-sdk-go-v2/service/ssm v1.66.4/go.mod h1:uNHuYAQazkHqpD+hVomA2+eDSuKJzerno7Fnha6N6/Y= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/ztictl/internal/ssm/manager.go b/ztictl/internal/ssm/manager.go index 4bec523..4f59336 100644 --- a/ztictl/internal/ssm/manager.go +++ b/ztictl/internal/ssm/manager.go @@ -12,6 +12,7 @@ import ( "regexp" "runtime" "strings" + "sync" "time" appconfig "ztictl/internal/config" @@ -38,8 +39,9 @@ var ( // Manager handles AWS Systems Manager operations type Manager struct { + mu sync.Mutex logger *logging.Logger - instanceService *awsservice.InstanceService // Add this + instanceService *awsservice.InstanceService iamManager *IAMManager s3LifecycleManager *S3LifecycleManager platformDetector *platform.Detector @@ -139,11 +141,13 @@ func (m *Manager) StartSession(ctx context.Context, instanceIdentifier, region s // initializePlatformComponents initializes platform detection components if not already done func (m *Manager) initializePlatformComponents(ctx context.Context, region string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.platformDetector != nil && m.builderManager != nil { - return nil // Already initialized + return nil } - // Get clients from pool ssmClient, ec2Client, err := m.clientPool.GetPlatformClients(ctx, region) if err != nil { return fmt.Errorf("failed to get AWS clients from pool: %w", err) @@ -170,21 +174,29 @@ func getAWSCommand() string { // initializeManagers initializes the IAM and S3 lifecycle managers func (m *Manager) initializeManagers(ctx context.Context, region string) error { - // Get clients from pool + m.mu.Lock() + defer m.mu.Unlock() + + if m.iamManager != nil && m.s3LifecycleManager != nil { + return nil + } + clients, err := m.clientPool.GetClients(ctx, region) if err != nil { return fmt.Errorf("failed to get AWS clients from pool: %w", err) } - // Initialize IAM manager - iamManager, err := NewIAMManager(m.logger, clients.IAMClient, clients.EC2Client) - if err != nil { - return fmt.Errorf("failed to create IAM manager: %w", err) + if m.iamManager == nil { + iamManager, err := NewIAMManager(m.logger, clients.IAMClient, clients.EC2Client) + if err != nil { + return fmt.Errorf("failed to create IAM manager: %w", err) + } + m.iamManager = iamManager } - m.iamManager = iamManager - // Initialize S3 lifecycle manager - m.s3LifecycleManager = NewS3LifecycleManager(m.logger, clients.S3Client, clients.STSClient) + if m.s3LifecycleManager == nil { + m.s3LifecycleManager = NewS3LifecycleManager(m.logger, clients.S3Client, clients.STSClient) + } return nil } @@ -567,7 +579,7 @@ func removeExitCodeLine(output string) string { } lines := strings.Split(output, "\n") - var filteredLines []string + filteredLines := make([]string, 0, len(lines)) for _, line := range lines { // Skip lines that start with EXIT_CODE: diff --git a/ztictl/internal/ssm/manager_test.go b/ztictl/internal/ssm/manager_test.go index f4fdaf4..f617fa3 100644 --- a/ztictl/internal/ssm/manager_test.go +++ b/ztictl/internal/ssm/manager_test.go @@ -1344,3 +1344,79 @@ func TestRemoveExitCodeLine(t *testing.T) { }) } } + +func TestConcurrentInitializePlatformComponents(t *testing.T) { + logger := logging.NewNoOpLogger() + manager := NewManager(logger) + ctx := context.Background() + region := "us-east-1" + + const numGoroutines = 50 + done := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func() { + done <- manager.initializePlatformComponents(ctx, region) + }() + } + + for i := 0; i < numGoroutines; i++ { + <-done + } + + if manager.platformDetector == nil && manager.builderManager == nil { + t.Skip("Platform components not initialized (expected without platform detection)") + } +} + +func TestConcurrentInitializeManagers(t *testing.T) { + logger := logging.NewNoOpLogger() + manager := NewManager(logger) + ctx := context.Background() + region := "us-east-1" + + const numGoroutines = 50 + done := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func() { + done <- manager.initializeManagers(ctx, region) + }() + } + + for i := 0; i < numGoroutines; i++ { + <-done + } + + if manager.iamManager == nil && manager.s3LifecycleManager == nil { + t.Skip("Managers not initialized (expected without AWS configuration)") + } +} + +func TestConcurrentMixedInitialization(t *testing.T) { + logger := logging.NewNoOpLogger() + manager := NewManager(logger) + ctx := context.Background() + region := "us-east-1" + + const numGoroutines = 100 + done := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + if i%2 == 0 { + go func() { + done <- manager.initializePlatformComponents(ctx, region) + }() + } else { + go func() { + done <- manager.initializeManagers(ctx, region) + }() + } + } + + for i := 0; i < numGoroutines; i++ { + <-done + } + + t.Log("Concurrent mixed initialization completed without race conditions") +} From ec8aadc8bf217df3b70583f97ef55fafe19e4240 Mon Sep 17 00:00:00 2001 From: ditahkk Date: Fri, 7 Nov 2025 08:13:16 -0500 Subject: [PATCH 2/6] Enhance AWS SSO and CI/CD Integration --- PRODUCTION_VERSION_FIX.md | 29 + README.md | 59 +- docs/CI_CD_AUTHENTICATION.md | 876 ++++++++++++++++++++++++++ docs/VERSION_CHECKING.md | 52 ++ docs/examples/github-actions-oidc.yml | 349 ++++++++++ docs/examples/gitlab-ci-oidc.yml | 348 ++++++++++ docs/examples/jenkins-iam-keys.groovy | 365 +++++++++++ ztictl/README.md | 105 +++ ztictl/cmd/ztictl/auth.go | 48 +- ztictl/cmd/ztictl/config.go | 51 +- ztictl/cmd/ztictl/config_test.go | 2 +- ztictl/cmd/ztictl/root.go | 220 ++++++- ztictl/cmd/ztictl/ssm_power.go | 73 ++- ztictl/internal/auth/sso.go | 84 +++ ztictl/internal/splash/splash.go | 119 ++-- ztictl/internal/splash/splash_test.go | 17 +- ztictl/internal/ssm/manager.go | 1 + ztictl/pkg/version/checker.go | 141 ++++- 18 files changed, 2808 insertions(+), 131 deletions(-) create mode 100644 PRODUCTION_VERSION_FIX.md create mode 100644 docs/CI_CD_AUTHENTICATION.md create mode 100644 docs/VERSION_CHECKING.md create mode 100644 docs/examples/github-actions-oidc.yml create mode 100644 docs/examples/gitlab-ci-oidc.yml create mode 100644 docs/examples/jenkins-iam-keys.groovy diff --git a/PRODUCTION_VERSION_FIX.md b/PRODUCTION_VERSION_FIX.md new file mode 100644 index 0000000..8ed0819 --- /dev/null +++ b/PRODUCTION_VERSION_FIX.md @@ -0,0 +1,29 @@ +# Version Check Production Fix + +## Problem +Version check failed silently in production due to: +- No proxy support (corporate firewalls block api.github.com) +- No TLS certificate handling (corporate proxies with custom CAs) +- GitHub rate limiting (60/hour unauthenticated) +- 3-second timeout insufficient for production networks +- Silent error suppression + +## Solution +Production-ready HTTP client with: +- `http.ProxyFromEnvironment` - respects standard proxy vars +- TLS 1.2+ with optional InsecureSkipVerify for corporate CAs +- GitHub token auth (5000/hour rate limit) +- 10-second timeout +- 3 retry attempts with exponential backoff +- Debug mode for troubleshooting + +## Files Modified +- `ztictl/pkg/version/checker.go` - Added createHTTPClient() with production features +- `docs/VERSION_CHECKING.md` - Environment variable reference + +## Environment Variables +- `ZTICTL_SKIP_VERSION_CHECK` - disable check +- `ZTICTL_DEBUG` - show errors +- `ZTICTL_INSECURE_SKIP_VERIFY` - skip TLS verification (corporate CAs only) +- `ZTICTL_HTTPS_PROXY` - custom proxy override +- `GITHUB_TOKEN` - authentication for rate limits diff --git a/README.md b/README.md index f818d8d..9c71eb4 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,61 @@ ztictl ssm --help See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for detailed configuration options. +#### CI/CD Pipeline Integration + +**ztictl** works seamlessly in CI/CD environments with automatic non-interactive mode detection. + +> **Important:** AWS SSO (`ztictl auth login`) requires browser interaction and **cannot be used in CI/CD**. Use IAM-based authentication instead (OIDC, EC2 instance profiles, or IAM access keys). + +**Quick Start for CI/CD:** +```bash +# ztictl auto-detects CI environments (GitHub Actions, GitLab CI, Jenkins, etc.) +# and disables interactive prompts automatically + +# Initialize configuration (non-interactive) +ztictl config init --non-interactive + +# List instances (table format for parsing) +ztictl ssm list --region ca-central-1 --table + +# Execute on specific instance +ztictl ssm exec i-1234567890abcdef0 --command "deploy.sh" --region ca-central-1 + +# Execute on multiple instances by tag +ztictl ssm exec-multi --tag Environment=production --command "deploy.sh" + +# Power management by tag +ztictl ssm power start --tag Environment=test +``` + +**Supported CI/CD Platforms:** +- GitHub Actions (OIDC recommended) +- GitLab CI (OIDC recommended) +- Jenkins (EC2 instance profile or IAM keys) +- CircleCI, AWS CodeBuild, and others + +**Complete Guide:** See [docs/CI_CD_AUTHENTICATION.md](docs/CI_CD_AUTHENTICATION.md) for: +- Authentication methods (OIDC, instance profiles, IAM keys) +- Platform-specific examples (GitHub Actions, GitLab CI, Jenkins) +- Environment variable reference +- Troubleshooting CI/CD issues + +**Example GitHub Actions Workflow:** +```yaml +- name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ca-central-1 + +- name: Deploy with ztictl + run: | + ztictl config init --non-interactive + ztictl ssm exec-multi --tag Environment=prod --command "deploy.sh" +``` + +See [docs/examples/](docs/examples/) for complete CI/CD workflow examples. + ### Legacy Bash Tools (Deprecated) > **⚠️ Deprecation Notice:** The bash tools are being phased out. New users should use `ztictl` above. @@ -368,8 +423,10 @@ For CI/CD pipeline architecture and development workflow, see [docs/CI_CD_PIPELI ### Core Documentation - **[Command Reference](docs/COMMANDS.md)** - Complete list of all commands with examples -- **[Configuration Guide](docs/CONFIGURATION.md)** - Detailed configuration file reference +- **[Configuration Guide](docs/CONFIGURATION.md)** - Detailed configuration file reference - **[Multi-Region Operations](docs/MULTI_REGION.md)** - Guide for cross-region command execution +- **[CI/CD Authentication](docs/CI_CD_AUTHENTICATION.md)** - Using ztictl in CI/CD pipelines +- **[Version Checking](docs/VERSION_CHECKING.md)** - Architecture and design decisions for version checking - **[Installation Guide](INSTALLATION.md)** - Platform-specific installation instructions - **[Troubleshooting](docs/TROUBLESHOOTING.md)** - Common issues and solutions - **[IAM Permissions](docs/IAM_PERMISSIONS.md)** - Required AWS permissions diff --git a/docs/CI_CD_AUTHENTICATION.md b/docs/CI_CD_AUTHENTICATION.md new file mode 100644 index 0000000..3b35058 --- /dev/null +++ b/docs/CI_CD_AUTHENTICATION.md @@ -0,0 +1,876 @@ +# CI/CD Authentication Guide + +## Overview + +This guide explains how to use `ztictl` in CI/CD pipelines where interactive authentication is not possible. + +**Key Point:** AWS SSO authentication (`ztictl auth login`) **cannot be used in CI/CD** because it requires browser interaction. Instead, you must use IAM-based authentication methods. + +## Table of Contents + +- [Why SSO Doesn't Work in CI/CD](#why-sso-doesnt-work-in-cicd) +- [Authentication Methods](#authentication-methods) +- [Recommended Approach by Platform](#recommended-approach-by-platform) +- [Configuration](#configuration) +- [Examples](#examples) +- [Troubleshooting](#troubleshooting) + +--- + +## Why SSO Doesn't Work in CI/CD + +AWS SSO uses the **OAuth2 Device Authorization Grant** flow (RFC 8628), which requires: + +1. User opens a browser +2. User navigates to AWS SSO portal +3. User enters a code displayed in the terminal +4. User authenticates with username/password or MFA +5. Browser redirects and provides credentials + +**CI/CD pipelines cannot:** +- Open web browsers +- Display interactive prompts +- Wait for user input +- Complete MFA challenges + +**Therefore:** `ztictl auth login` will always fail in automated environments. + +--- + +## Authentication Methods + +### Method 1: OIDC Federation (Recommended) + +**Best for:** GitHub Actions, GitLab CI, modern CI/CD platforms + +**Advantages:** +- No long-lived credentials stored +- Automatic credential rotation +- Fine-grained permission control +- Audit trail via CloudTrail + +**How it works:** +1. CI/CD platform provides OIDC token +2. AWS STS exchanges token for temporary credentials +3. `ztictl` uses temporary credentials via AWS SDK + +**Platforms with OIDC support:** +- GitHub Actions +- GitLab CI/CD +- CircleCI +- Bitbucket Pipelines +- Azure DevOps + +### Method 2: EC2 Instance Profile + +**Best for:** Self-hosted runners on EC2 instances + +**Advantages:** +- No credential management needed +- Automatic credential rotation +- Seamless integration with AWS services + +**How it works:** +1. Attach IAM role to EC2 instance +2. Instance metadata service provides credentials +3. `ztictl` automatically uses instance credentials + +### Method 3: IAM Access Keys + +**Best for:** Quick testing, legacy systems + +**Disadvantages:** +- Long-lived credentials (security risk) +- Manual rotation required +- Stored in secrets manager (additional exposure) + +**Use only when:** +- OIDC not available +- Not running on AWS infrastructure +- Temporary solution during migration + +**How it works:** +1. Create IAM user with access keys +2. Store keys in CI/CD secrets +3. Export as environment variables +4. `ztictl` uses credentials via AWS SDK + +### Method 4: ECS Task Role + +**Best for:** Containerized CI/CD on ECS/Fargate + +**Advantages:** +- No credential management +- Automatic credential rotation +- Container-level isolation + +**How it works:** +1. Define task role in ECS task definition +2. ECS provides credentials to container +3. `ztictl` uses credentials automatically + +--- + +## Recommended Approach by Platform + +### GitHub Actions + +**Recommended:** OIDC Federation + +**Required IAM Role Trust Policy:** +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:*" + } + } + } + ] +} +``` + +**Workflow Example:** +```yaml +name: Deploy with ztictl +on: [push] + +permissions: + id-token: write # Required for OIDC + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole + aws-region: ca-central-1 + + - name: Install ztictl + run: | + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + + - name: Initialize ztictl config + run: ztictl config init --non-interactive + env: + ZTICTL_DEFAULT_REGION: ca-central-1 + + - name: List instances + run: ztictl ssm list --table +``` + +See: [docs/examples/github-actions-oidc.yml](examples/github-actions-oidc.yml) + +### GitLab CI + +**Recommended:** OIDC Federation + +**Required IAM Role Trust Policy:** +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::123456789012:oidc-provider/gitlab.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "gitlab.com:aud": "https://gitlab.com" + }, + "StringLike": { + "gitlab.com:sub": "project_path:your-group/your-project:ref_type:branch:ref:main" + } + } + } + ] +} +``` + +**Pipeline Example:** +```yaml +deploy: + image: alpine:latest + id_tokens: + GITLAB_OIDC_TOKEN: + aud: https://gitlab.com + before_script: + - apk add --no-cache aws-cli curl + - | + export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/web-identity-token + echo $GITLAB_OIDC_TOKEN > $AWS_WEB_IDENTITY_TOKEN_FILE + export AWS_ROLE_ARN=arn:aws:iam::123456789012:role/GitLabCIRole + export AWS_REGION=ca-central-1 + - | + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + mv ztictl /usr/local/bin/ + script: + - ztictl config init --non-interactive + - ztictl ssm list --table + variables: + ZTICTL_DEFAULT_REGION: ca-central-1 +``` + +See: [docs/examples/gitlab-ci-oidc.yml](examples/gitlab-ci-oidc.yml) + +### Jenkins + +**Recommended:** EC2 Instance Profile (if self-hosted on EC2) or IAM Access Keys + +**Option A: EC2 Instance Profile** +```groovy +pipeline { + agent { label 'ec2' } // EC2-based Jenkins agent + + stages { + stage('Deploy') { + steps { + sh ''' + # Credentials automatically from instance metadata + ztictl config init --non-interactive + ztictl ssm list --table + ''' + } + } + } + + environment { + ZTICTL_DEFAULT_REGION = 'ca-central-1' + ZTICTL_NON_INTERACTIVE = 'true' + } +} +``` + +**Option B: IAM Access Keys (stored in Jenkins credentials)** +```groovy +pipeline { + agent any + + stages { + stage('Deploy') { + steps { + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + sh ''' + ztictl config init --non-interactive + ztictl ssm list --table + ''' + } + } + } + } + + environment { + AWS_DEFAULT_REGION = 'ca-central-1' + ZTICTL_DEFAULT_REGION = 'ca-central-1' + ZTICTL_NON_INTERACTIVE = 'true' + } +} +``` + +See: [docs/examples/jenkins-iam-keys.groovy](examples/jenkins-iam-keys.groovy) + +### CircleCI + +**Recommended:** OIDC Federation or IAM Access Keys + +**OIDC Example:** +```yaml +version: 2.1 + +orbs: + aws-cli: circleci/aws-cli@3.1 + +jobs: + deploy: + docker: + - image: cimg/base:stable + steps: + - checkout + - aws-cli/setup: + role-arn: arn:aws:iam::123456789012:role/CircleCIRole + aws-region: ca-central-1 + - run: + name: Install ztictl + command: | + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + - run: + name: Configure ztictl + command: ztictl config init --non-interactive + environment: + ZTICTL_DEFAULT_REGION: ca-central-1 + - run: + name: List instances + command: ztictl ssm list --table + +workflows: + deploy: + jobs: + - deploy +``` + +### AWS CodeBuild + +**Recommended:** Service Role (automatic) + +**buildspec.yml:** +```yaml +version: 0.2 + +phases: + install: + commands: + - curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + - chmod +x ztictl + - mv ztictl /usr/local/bin/ + + pre_build: + commands: + - export ZTICTL_DEFAULT_REGION=$AWS_DEFAULT_REGION + - ztictl config init --non-interactive + + build: + commands: + - ztictl ssm list --table + - ztictl ssm exec $AWS_DEFAULT_REGION $INSTANCE_ID "deploy.sh" + +env: + variables: + ZTICTL_NON_INTERACTIVE: "true" +``` + +**Note:** CodeBuild automatically provides credentials via the service role attached to the build project. + +--- + +## Configuration + +### Required Permissions + +The IAM role or user must have permissions for the operations you plan to perform: + +**SSM Operations:** +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeTags", + "ssm:DescribeInstanceInformation", + "ssm:StartSession", + "ssm:SendCommand", + "ssm:GetCommandInvocation", + "ssm:TerminateSession" + ], + "Resource": "*" + } + ] +} +``` + +For complete permission requirements, see: [IAM_PERMISSIONS.md](IAM_PERMISSIONS.md) + +### Environment Variables + +**Required for ztictl:** +```bash +# AWS credentials (provided automatically by most CI/CD platforms) +AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE # (or via OIDC/instance profile) +AWS_SECRET_ACCESS_KEY=wJalrXUt... # (or via OIDC/instance profile) +AWS_DEFAULT_REGION=ca-central-1 # AWS SDK default region + +# ztictl configuration +ZTICTL_DEFAULT_REGION=ca-central-1 # Default region for operations +ZTICTL_NON_INTERACTIVE=true # Disable all interactive prompts +``` + +**Optional:** +```bash +ZTICTL_LOG_ENABLED=false # Disable file logging in CI +ZTICTL_LOG_LEVEL=info # Log verbosity +ZTICTL_REGIONS=us-east-1,ca-central-1 # Regions to operate in +ZTICTL_INSTANCE_ID=i-1234567890abcdef0 # Default instance for SSM commands +``` + +### Non-Interactive Mode + +`ztictl` automatically detects CI/CD environments and enables non-interactive mode when: + +1. `CI` environment variable is set (most CI/CD platforms set this) +2. `ZTICTL_NON_INTERACTIVE=true` is set explicitly +3. `--non-interactive` flag is used + +**In non-interactive mode:** +- Splash screen is suppressed +- All interactive prompts are skipped +- Commands requiring input will fail with clear error messages +- Operations use environment variables or fail fast + +**Example:** +```bash +# Explicit non-interactive mode +ztictl --non-interactive ssm list --table + +# Via environment variable +export ZTICTL_NON_INTERACTIVE=true +ztictl ssm list --table + +# Auto-detected (CI=true set by platform) +ztictl ssm list --table # Works automatically in CI +``` + +--- + +## Examples + +### Complete GitHub Actions Workflow + +```yaml +name: Deploy Application +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Configure AWS Credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ca-central-1 + + - name: Install ztictl + run: | + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + ztictl --version + + - name: Initialize ztictl + run: ztictl config init --non-interactive + env: + ZTICTL_DEFAULT_REGION: ca-central-1 + + - name: List all instances + run: ztictl ssm list --region ca-central-1 --table + + - name: Deploy to production instances via tags + run: | + ztictl ssm exec-tagged ca-central-1 \ + --tags Name=web-server-prod \ + "cd /app && git pull && systemctl restart app" + + - name: Verify deployment + run: | + ztictl ssm exec-tagged ca-central-1 \ + --tags Name=web-server-prod \ + "systemctl status app" +``` + +### Multi-Region Deployment + +```yaml +- name: Deploy to all regions + run: | + for region in us-east-1 ca-central-1 eu-west-1; do + echo "Deploying to $region..." + ztictl ssm exec-tagged $region \ + --tags Environment=production,Service=web \ + "/app/deploy.sh" + done +``` + +### Instance Power Management + +```yaml +- name: Start all test instances + run: | + ztictl ssm start-tagged \ + --region ca-central-1 \ + --tags Environment=test \ + --tags AutoStart=true + +- name: Wait for instances to be ready + run: sleep 60 + +- name: Run integration tests + run: | + ztictl ssm exec-tagged ca-central-1 \ + --tags Environment=test \ + "cd /app && npm test" + +- name: Stop test instances + if: always() + run: | + ztictl ssm stop-tagged \ + --region ca-central-1 \ + --tags Environment=test +``` + +### File Transfer + +```yaml +- name: Upload configuration files + run: | + ztictl ssm transfer upload \ + i-1234567890abcdef0 \ + ./config/production.json \ + /etc/app/config.json \ + --region ca-central-1 + +- name: Download logs + run: | + ztictl ssm transfer download \ + i-1234567890abcdef0 \ + /var/log/app/error.log \ + ./artifacts/error.log \ + --region ca-central-1 + +- name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: logs + path: artifacts/ +``` + +--- + +## Troubleshooting + +### "Unable to locate credentials" + +**Problem:** AWS SDK cannot find credentials + +**Solutions:** +1. Verify OIDC configuration is correct +2. Check that `id-token: write` permission is set (GitHub Actions) +3. Verify IAM role trust policy allows your CI/CD platform +4. Check environment variables are set correctly + +**Debug:** +```bash +# Check which credentials are being used +aws sts get-caller-identity + +# Verify credentials work +aws ec2 describe-instances --region ca-central-1 --max-items 1 +``` + +### "Access Denied" errors + +**Problem:** IAM role/user lacks required permissions + +**Solutions:** +1. Review [IAM_PERMISSIONS.md](IAM_PERMISSIONS.md) for required permissions +2. Attach appropriate IAM policies to role/user +3. Verify resource-based policies allow access + +**Debug:** +```bash +# Check current identity +aws sts get-caller-identity + +# Test specific permission +aws ssm describe-instance-information --region ca-central-1 --max-items 1 +``` + +### "non-interactive mode requires instance ID" + +**Problem:** SSM command needs explicit instance identifier in CI + +**Solutions:** +1. Provide instance ID as argument: + ```bash + ztictl ssm connect i-1234567890abcdef0 --region ca-central-1 + ``` + +2. Use instance name (auto-resolved): + ```bash + ztictl ssm connect web-server-prod --region ca-central-1 + ``` + +3. Use tag-based commands instead: + ```bash + ztictl ssm exec-tagged ca-central-1 --tags Name=web-server-prod "uptime" + ``` + +### "AWS SSO authentication requires browser" + +**Problem:** Trying to use `ztictl auth login` in CI/CD + +**Solution:** Don't use SSO in CI/CD. Use IAM-based authentication instead (see above). + +This error appears when: +- Running `ztictl auth login` in a CI environment +- CI environment is detected (CI=true) but no AWS credentials exist + +**Fix:** Configure IAM credentials via OIDC, instance profile, or access keys. + +### Session Manager plugin not found + +**Problem:** `session-manager-plugin` not installed + +**Solutions:** + +**Ubuntu/Debian:** +```bash +curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" -o "session-manager-plugin.deb" +sudo dpkg -i session-manager-plugin.deb +``` + +**Amazon Linux/RHEL:** +```bash +curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/linux_64bit/session-manager-plugin.rpm" -o "session-manager-plugin.rpm" +sudo yum install -y session-manager-plugin.rpm +``` + +**Docker (add to Dockerfile):** +```dockerfile +RUN curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" -o "/tmp/session-manager-plugin.deb" && \ + dpkg -i /tmp/session-manager-plugin.deb && \ + rm /tmp/session-manager-plugin.deb +``` + +### Splash screen blocks execution + +**Problem:** First-run splash screen waits for input + +**Solution:** Should auto-detect CI and skip splash. If not working: + +```bash +# Force non-interactive mode +export ZTICTL_NON_INTERACTIVE=true +ztictl ssm list +``` + +If issue persists, verify CI environment variable is set: +```bash +export CI=true +``` + +--- + +## Best Practices + +### 1. Use OIDC When Possible +- No credential management +- Automatic rotation +- Better security posture +- Audit trail + +### 2. Minimize Permission Scope +- Use least-privilege IAM policies +- Scope permissions to specific resources when possible +- Separate roles for different environments + +### 3. Use Tag-Based Operations +- More resilient than instance IDs +- Works with auto-scaling groups +- Easier to manage + +```bash +# Use tag-based commands for multiple instances +ztictl ssm exec-tagged ca-central-1 --tags Environment=prod "deploy.sh" + +# Instead of operating on a single instance +ztictl ssm exec ca-central-1 i-1234567890abcdef0 "deploy.sh" +``` + +### 4. Set Explicit Regions +- Don't rely on defaults +- Specify region in commands or environment variables + +```yaml +env: + AWS_DEFAULT_REGION: ca-central-1 + ZTICTL_DEFAULT_REGION: ca-central-1 +``` + +### 5. Enable Debug Logging for Troubleshooting +```yaml +env: + ZTICTL_LOG_ENABLED: "true" + ZTICTL_LOG_LEVEL: "debug" + ZTICTL_LOG_DIR: "/tmp/ztictl-logs" + +# Upload logs as artifacts +- uses: actions/upload-artifact@v3 + if: failure() + with: + name: ztictl-logs + path: /tmp/ztictl-logs/ +``` + +### 6. Use `--table` Output for Parsing +```bash +# Machine-readable table output +ztictl ssm list --table --region ca-central-1 | grep running +``` + +### 7. Test with `--dry-run` When Available +```bash +ztictl ssm cleanup --region ca-central-1 --dry-run +``` + +--- + +## Security Considerations + +### 1. Rotate Access Keys Regularly +If using IAM access keys: +- Rotate every 90 days minimum +- Use AWS Secrets Manager or CI/CD secrets +- Never commit keys to source control + +### 2. Use Temporary Credentials +- OIDC provides short-lived credentials (1 hour default) +- Instance profiles rotate automatically +- Avoid long-lived access keys + +### 3. Restrict OIDC Trust Policies +```json +{ + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main" + } + } +} +``` + +This restricts role assumption to: +- Specific repository +- Specific branch (main) +- Prevents unauthorized use + +### 4. Audit CloudTrail Logs +- Monitor API calls from CI/CD +- Set up alerts for unusual activity +- Review access patterns regularly + +### 5. Separate Roles by Environment +``` +GitHubActions-Dev-Role → Limited permissions, dev resources +GitHubActions-Prod-Role → Full permissions, prod resources +``` + +Restrict production role to protected branches only. + +--- + +## Additional Resources + +- [IAM_PERMISSIONS.md](IAM_PERMISSIONS.md) - Required IAM permissions +- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - General troubleshooting +- [GitHub Actions OIDC Guide](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services) +- [GitLab CI OIDC Guide](https://docs.gitlab.com/ee/ci/cloud_services/aws/) +- [AWS Session Manager Plugin Installation](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html) + +--- + +## Quick Reference + +### Authentication Method Decision Tree + +``` +Are you running on AWS infrastructure? +├─ Yes → Use EC2 Instance Profile or ECS Task Role +└─ No + └─ Does your CI/CD platform support OIDC? + ├─ Yes → Use OIDC Federation (recommended) + └─ No → Use IAM Access Keys (rotate regularly) +``` + +### Common Commands in CI/CD + +```bash +# Initialize configuration +ztictl config init --non-interactive + +# List instances (table format for parsing) +ztictl ssm list --region ca-central-1 --table + +# Execute on specific instance (with region shortcode) +ztictl ssm exec cac1 i-1234567890abcdef0 "uptime" + +# Execute on multiple instances by tag +ztictl ssm exec-tagged cac1 --tags Environment=prod "deploy.sh" + +# Power management by tag +ztictl ssm start-tagged --region ca-central-1 --tags Environment=test +ztictl ssm stop-tagged --region ca-central-1 --tags Environment=test +ztictl ssm reboot-tagged --region ca-central-1 --tags Environment=test + +# Power management for single instance +ztictl ssm start i-1234567890abcdef0 --region ca-central-1 +ztictl ssm stop i-1234567890abcdef0 --region ca-central-1 +ztictl ssm reboot i-1234567890abcdef0 --region ca-central-1 + +# File transfer +ztictl ssm transfer upload i-xxx ./local.txt /remote/path/file.txt --region cac1 +ztictl ssm transfer download i-xxx /remote/log.txt ./local.txt --region cac1 + +# Cleanup old resources +ztictl ssm cleanup --region ca-central-1 --dry-run +``` + +### Environment Variables Checklist + +```bash +# AWS SDK (auto-provided by OIDC/instance profile) +✓ AWS_ACCESS_KEY_ID +✓ AWS_SECRET_ACCESS_KEY +✓ AWS_DEFAULT_REGION + +# ztictl required +✓ ZTICTL_DEFAULT_REGION + +# ztictl optional +○ ZTICTL_NON_INTERACTIVE=true +○ ZTICTL_INSTANCE_ID=i-xxx +○ ZTICTL_LOG_ENABLED=false +``` + +--- + +**Need help?** Open an issue at https://github.com/zsoftly/ztiaws/issues diff --git a/docs/VERSION_CHECKING.md b/docs/VERSION_CHECKING.md new file mode 100644 index 0000000..8acf968 --- /dev/null +++ b/docs/VERSION_CHECKING.md @@ -0,0 +1,52 @@ +# Version Checking + +## Architecture + +Version checker uses production-ready HTTP client to check GitHub releases API. + +### Design Decisions + +**Proxy Support** +- `http.ProxyFromEnvironment` respects standard HTTP_PROXY, HTTPS_PROXY, NO_PROXY +- Custom override via ZTICTL_HTTPS_PROXY for tool-specific proxy configuration +- Rationale: Corporate environments require proxy support without modifying system-wide settings + +**TLS Configuration** +- Minimum TLS 1.2 +- Optional InsecureSkipVerify for corporate proxy with custom certificates +- Rationale: Balance security with corporate environment compatibility + +**Retry Logic** +- 3 attempts with exponential backoff (1s base delay) +- Skip retry on 4xx client errors +- Rationale: Transient network failures common in production; client errors are permanent + +**Rate Limiting** +- Unauthenticated: 60 requests/hour (GitHub limit) +- With GITHUB_TOKEN: 5000 requests/hour +- 24-hour cache to minimize API calls +- Rationale: Prevent rate limit exhaustion in CI/CD environments + +**Error Handling** +- Silent by default (non-blocking) +- Debug mode (ZTICTL_DEBUG) shows detailed errors +- Disable option (ZTICTL_SKIP_VERSION_CHECK) for air-gapped environments +- Rationale: Version check should never block primary operations + +## Environment Variables + +| Variable | Purpose | +|----------|---------| +| ZTICTL_SKIP_VERSION_CHECK | Disable version checking | +| ZTICTL_DEBUG | Show detailed errors | +| ZTICTL_VERBOSE_VERSION | Show success messages | +| ZTICTL_HTTPS_PROXY | Custom proxy override | +| ZTICTL_INSECURE_SKIP_VERIFY | Skip TLS verification | +| GITHUB_TOKEN | GitHub API authentication | + +## Implementation + +- File: `ztictl/pkg/version/checker.go` +- Function: `createHTTPClient()` - production HTTP client factory +- Function: `CheckLatestVersion()` - retry logic and caching +- Function: `PrintVersionWithCheck()` - output formatting diff --git a/docs/examples/github-actions-oidc.yml b/docs/examples/github-actions-oidc.yml new file mode 100644 index 0000000..f9be86d --- /dev/null +++ b/docs/examples/github-actions-oidc.yml @@ -0,0 +1,349 @@ +# GitHub Actions - OIDC Authentication Example +# +# This workflow demonstrates how to use ztictl in GitHub Actions with OIDC authentication. +# OIDC provides short-lived credentials without storing access keys. +# +# Prerequisites: +# 1. Create an IAM OIDC identity provider for GitHub Actions +# 2. Create an IAM role with the appropriate trust policy (see below) +# 3. Attach necessary permissions to the role (see docs/IAM_PERMISSIONS.md) +# 4. Store the role ARN in GitHub secrets as AWS_ROLE_ARN +# +# IAM Role Trust Policy Example: +# { +# "Version": "2012-10-17", +# "Statement": [ +# { +# "Effect": "Allow", +# "Principal": { +# "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" +# }, +# "Action": "sts:AssumeRoleWithWebIdentity", +# "Condition": { +# "StringEquals": { +# "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" +# }, +# "StringLike": { +# "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:*" +# } +# } +# } +# ] +# } + +name: Deploy with ztictl (OIDC) + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + workflow_dispatch: + +# Required for OIDC authentication +permissions: + id-token: write # Required to request OIDC token + contents: read # Required to checkout code + +env: + AWS_REGION: ca-central-1 + ZTICTL_DEFAULT_REGION: ca-central-1 + +jobs: + deploy: + name: Deploy to AWS + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + # Configure AWS credentials using OIDC + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + role-session-name: GitHubActions-${{ github.run_id }} + + # Verify credentials work + - name: Verify AWS credentials + run: | + echo "Verifying AWS credentials..." + aws sts get-caller-identity + echo "Credentials verified successfully" + + # Install ztictl + - name: Install ztictl + run: | + echo "Installing ztictl..." + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + ztictl --version + + # Install AWS Session Manager plugin (required for ssm connect) + - name: Install Session Manager plugin + run: | + echo "Installing AWS Session Manager plugin..." + curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" \ + -o "session-manager-plugin.deb" + sudo dpkg -i session-manager-plugin.deb + session-manager-plugin --version + + # Initialize ztictl configuration + - name: Initialize ztictl config + run: | + echo "Initializing ztictl configuration..." + ztictl config init --non-interactive + env: + ZTICTL_DEFAULT_REGION: ${{ env.AWS_REGION }} + ZTICTL_LOG_ENABLED: "false" + + # Verify configuration + - name: Verify ztictl configuration + run: | + echo "Verifying ztictl configuration..." + ztictl config check + + # List EC2 instances + - name: List EC2 instances + run: | + echo "Listing EC2 instances..." + ztictl ssm list --region ${{ env.AWS_REGION }} --table + + # Example: Deploy to specific instance + - name: Deploy to production instance + if: github.ref == 'refs/heads/main' + run: | + echo "Deploying to production..." + ztictl ssm exec ${{ env.AWS_REGION }} web-server-prod \ + "cd /app && git pull && systemctl restart app" + + # Example: Deploy to multiple instances using tags + - name: Deploy to all web servers + if: github.ref == 'refs/heads/main' + run: | + echo "Deploying to all web servers..." + ztictl ssm exec-tagged ${{ env.AWS_REGION }} \ + --tags Environment=production,Service=web \ + "/app/deploy.sh" + + # Example: Verify deployment + - name: Verify deployment + if: github.ref == 'refs/heads/main' + run: | + echo "Verifying deployment..." + ztictl ssm exec ${{ env.AWS_REGION }} web-server-prod \ + "systemctl status app" + + test-environment: + name: Test Environment Management + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Install ztictl + run: | + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + + - name: Initialize ztictl + run: ztictl config init --non-interactive + env: + ZTICTL_DEFAULT_REGION: ${{ env.AWS_REGION }} + + # Start test instances + - name: Start test instances + run: | + echo "Starting test instances..." + ztictl ssm start-tagged \ + --region ${{ env.AWS_REGION }} \ + --tags Environment=test,AutoStart=true + + # Wait for instances to be ready + - name: Wait for instances + run: | + echo "Waiting for instances to be ready..." + sleep 60 + + # Run tests + - name: Run integration tests + run: | + echo "Running integration tests..." + ztictl ssm exec-tagged ${{ env.AWS_REGION }} \ + --tags Environment=test \ + "cd /app && npm test" + + # Stop test instances (always run, even if tests fail) + - name: Stop test instances + if: always() + run: | + echo "Stopping test instances..." + ztictl ssm stop-tagged \ + --region ${{ env.AWS_REGION }} \ + --tags Environment=test + + file-transfer: + name: File Transfer Example + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Install ztictl and Session Manager plugin + run: | + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" \ + -o "session-manager-plugin.deb" + sudo dpkg -i session-manager-plugin.deb + + - name: Initialize ztictl + run: ztictl config init --non-interactive + env: + ZTICTL_DEFAULT_REGION: ${{ env.AWS_REGION }} + + # Upload configuration file + - name: Upload configuration + run: | + echo "Uploading configuration file..." + ztictl ssm transfer upload \ + ${{ secrets.PRODUCTION_INSTANCE_ID }} \ + ./config/production.json \ + /etc/app/config.json \ + --region ${{ env.AWS_REGION }} + + # Download logs for artifact storage + - name: Download application logs + run: | + echo "Downloading application logs..." + mkdir -p artifacts + ztictl ssm transfer download \ + ${{ secrets.PRODUCTION_INSTANCE_ID }} \ + /var/log/app/error.log \ + ./artifacts/error.log \ + --region ${{ env.AWS_REGION }} + + # Upload logs as GitHub artifacts + - name: Upload logs as artifacts + uses: actions/upload-artifact@v3 + if: always() + with: + name: application-logs + path: artifacts/ + retention-days: 30 + + multi-region: + name: Multi-Region Deployment + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + permissions: + id-token: write + contents: read + + strategy: + matrix: + region: [us-east-1, ca-central-1, eu-west-1] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ matrix.region }} + + - name: Install ztictl + run: | + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + + - name: Initialize ztictl + run: ztictl config init --non-interactive + env: + ZTICTL_DEFAULT_REGION: ${{ matrix.region }} + + - name: Deploy to region + run: | + echo "Deploying to ${{ matrix.region }}..." + ztictl ssm exec-tagged ${{ matrix.region }} \ + --tags Environment=production,Service=web \ + "/app/deploy.sh" + + - name: Verify deployment + run: | + echo "Verifying deployment in ${{ matrix.region }}..." + ztictl ssm exec-tagged ${{ matrix.region }} \ + --tags Environment=production,Service=web \ + "curl -f http://localhost:8080/health || exit 1" + + cleanup: + name: Cleanup Old Resources + runs-on: ubuntu-latest + if: github.event_name == 'schedule' + + permissions: + id-token: write + contents: read + + steps: + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Install ztictl + run: | + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + + - name: Initialize ztictl + run: ztictl config init --non-interactive + env: + ZTICTL_DEFAULT_REGION: ${{ env.AWS_REGION }} + + # Dry run first to see what would be cleaned + - name: Cleanup dry run + run: | + echo "Running cleanup dry run..." + ztictl ssm cleanup --region ${{ env.AWS_REGION }} --dry-run + + # Actual cleanup + - name: Cleanup old resources + run: | + echo "Cleaning up old resources..." + ztictl ssm cleanup --region ${{ env.AWS_REGION }} diff --git a/docs/examples/gitlab-ci-oidc.yml b/docs/examples/gitlab-ci-oidc.yml new file mode 100644 index 0000000..14ee2f8 --- /dev/null +++ b/docs/examples/gitlab-ci-oidc.yml @@ -0,0 +1,348 @@ +# GitLab CI - OIDC Authentication Example +# +# This pipeline demonstrates how to use ztictl in GitLab CI with OIDC authentication. +# OIDC provides short-lived credentials without storing access keys. +# +# Prerequisites: +# 1. Create an IAM OIDC identity provider for GitLab +# 2. Create an IAM role with the appropriate trust policy (see below) +# 3. Attach necessary permissions to the role (see docs/IAM_PERMISSIONS.md) +# 4. Set CI/CD variables in GitLab: +# - AWS_ROLE_ARN: ARN of the IAM role to assume +# - AWS_REGION: Default AWS region (e.g., ca-central-1) +# +# IAM Role Trust Policy Example: +# { +# "Version": "2012-10-17", +# "Statement": [ +# { +# "Effect": "Allow", +# "Principal": { +# "Federated": "arn:aws:iam::123456789012:oidc-provider/gitlab.com" +# }, +# "Action": "sts:AssumeRoleWithWebIdentity", +# "Condition": { +# "StringEquals": { +# "gitlab.com:aud": "https://gitlab.com" +# }, +# "StringLike": { +# "gitlab.com:sub": "project_path:your-group/your-project:ref_type:branch:ref:main" +# } +# } +# } +# ] +# } + +variables: + AWS_REGION: ca-central-1 + ZTICTL_DEFAULT_REGION: ca-central-1 + ZTICTL_NON_INTERACTIVE: "true" + ZTICTL_LOG_ENABLED: "false" + +stages: + - setup + - deploy + - test + - cleanup + +# Define OIDC token for authentication +.aws_auth: + id_tokens: + GITLAB_OIDC_TOKEN: + aud: https://gitlab.com + before_script: + # Configure AWS credentials using OIDC token + - | + export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/web-identity-token + echo $GITLAB_OIDC_TOKEN > $AWS_WEB_IDENTITY_TOKEN_FILE + export AWS_ROLE_ARN=$AWS_ROLE_ARN + export AWS_DEFAULT_REGION=$AWS_REGION + # Install AWS CLI + - apk add --no-cache aws-cli curl bash + # Verify AWS credentials + - aws sts get-caller-identity + # Install ztictl + - | + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + mv ztictl /usr/local/bin/ + ztictl --version + # Install AWS Session Manager plugin + - | + curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" \ + -o "session-manager-plugin.deb" + apk add --no-cache dpkg + dpkg -i session-manager-plugin.deb || true + # Initialize ztictl configuration + - ztictl config init --non-interactive + +verify-config: + stage: setup + image: alpine:latest + extends: .aws_auth + script: + - echo "Verifying ztictl configuration..." + - ztictl config check + - echo "Configuration verified successfully" + only: + - branches + +list-instances: + stage: setup + image: alpine:latest + extends: .aws_auth + script: + - echo "Listing EC2 instances..." + - ztictl ssm list --region $AWS_REGION --table + artifacts: + reports: + dotenv: instance-list.env + only: + - branches + +deploy-production: + stage: deploy + image: alpine:latest + extends: .aws_auth + script: + - echo "Deploying to production..." + - | + ztictl ssm exec $AWS_REGION web-server-prod \ + "cd /app && git pull && systemctl restart app" + only: + - main + environment: + name: production + +deploy-all-web-servers: + stage: deploy + image: alpine:latest + extends: .aws_auth + script: + - echo "Deploying to all web servers..." + - | + ztictl ssm exec-tagged $AWS_REGION \ + --tags Environment=production,Service=web \ + "/app/deploy.sh" + only: + - main + environment: + name: production + +verify-deployment: + stage: test + image: alpine:latest + extends: .aws_auth + script: + - echo "Verifying deployment..." + - | + ztictl ssm exec $AWS_REGION web-server-prod \ + "systemctl status app && curl -f http://localhost:8080/health" + only: + - main + needs: + - deploy-production + +# Test environment management +start-test-instances: + stage: setup + image: alpine:latest + extends: .aws_auth + script: + - echo "Starting test instances..." + - | + ztictl ssm start-tagged \ + --region $AWS_REGION \ + --tags Environment=test,AutoStart=true + - echo "Waiting for instances to be ready..." + - sleep 60 + only: + - merge_requests + environment: + name: test + on_stop: stop-test-instances + +run-integration-tests: + stage: test + image: alpine:latest + extends: .aws_auth + script: + - echo "Running integration tests..." + - | + ztictl ssm exec-tagged $AWS_REGION \ + --tags Environment=test \ + "cd /app && npm test" + only: + - merge_requests + needs: + - start-test-instances + +stop-test-instances: + stage: cleanup + image: alpine:latest + extends: .aws_auth + script: + - echo "Stopping test instances..." + - | + ztictl ssm stop-tagged \ + --region $AWS_REGION \ + --tags Environment=test + when: manual + only: + - merge_requests + environment: + name: test + action: stop + +# File transfer example +upload-configuration: + stage: deploy + image: alpine:latest + extends: .aws_auth + script: + - echo "Uploading configuration file..." + - | + ztictl ssm transfer upload \ + $PRODUCTION_INSTANCE_ID \ + ./config/production.json \ + /etc/app/config.json \ + --region $AWS_REGION + only: + - main + +download-logs: + stage: test + image: alpine:latest + extends: .aws_auth + script: + - echo "Downloading application logs..." + - mkdir -p artifacts + - | + ztictl ssm transfer download \ + $PRODUCTION_INSTANCE_ID \ + /var/log/app/error.log \ + ./artifacts/error.log \ + --region $AWS_REGION + artifacts: + paths: + - artifacts/ + expire_in: 30 days + when: on_failure + only: + - main + +# Multi-region deployment +.deploy-region: + stage: deploy + image: alpine:latest + extends: .aws_auth + script: + - echo "Deploying to $DEPLOY_REGION..." + - | + ztictl ssm exec-tagged $DEPLOY_REGION \ + --tags Environment=production,Service=web \ + "/app/deploy.sh" + - echo "Verifying deployment in $DEPLOY_REGION..." + - | + ztictl ssm exec-tagged $DEPLOY_REGION \ + --tags Environment=production,Service=web \ + "curl -f http://localhost:8080/health || exit 1" + only: + - main + +deploy-us-east-1: + extends: .deploy-region + variables: + DEPLOY_REGION: us-east-1 + +deploy-ca-central-1: + extends: .deploy-region + variables: + DEPLOY_REGION: ca-central-1 + +deploy-eu-west-1: + extends: .deploy-region + variables: + DEPLOY_REGION: eu-west-1 + +# Scheduled cleanup job +cleanup-old-resources: + stage: cleanup + image: alpine:latest + extends: .aws_auth + script: + - echo "Running cleanup dry run..." + - ztictl ssm cleanup --region $AWS_REGION --dry-run + - echo "Cleaning up old resources..." + - ztictl ssm cleanup --region $AWS_REGION + only: + - schedules + when: manual + +# Development branch deployment +deploy-dev: + stage: deploy + image: alpine:latest + extends: .aws_auth + script: + - echo "Deploying to development environment..." + - | + ztictl ssm exec-tagged $AWS_REGION \ + --tags Environment=development \ + "cd /app && git fetch && git checkout $CI_COMMIT_REF_NAME && git pull && systemctl restart app" + only: + - develop + environment: + name: development + +# Canary deployment example +deploy-canary: + stage: deploy + image: alpine:latest + extends: .aws_auth + script: + - echo "Deploying canary release..." + - | + ztictl ssm exec $AWS_REGION canary-instance \ + "cd /app && git pull && systemctl restart app" + - echo "Waiting for canary health checks..." + - sleep 30 + - | + ztictl ssm exec $AWS_REGION canary-instance \ + "curl -f http://localhost:8080/health || exit 1" + only: + - main + when: manual + +deploy-full-rollout: + stage: deploy + image: alpine:latest + extends: .aws_auth + script: + - echo "Full rollout after canary success..." + - | + ztictl ssm exec-tagged $AWS_REGION \ + --tags Environment=production,Canary=false \ + "cd /app && git pull && systemctl restart app" + only: + - main + needs: + - deploy-canary + when: manual + +# Health check job +health-check: + stage: test + image: alpine:latest + extends: .aws_auth + script: + - echo "Running health checks on all production instances..." + - | + ztictl ssm exec-tagged $AWS_REGION \ + --tags Environment=production \ + "curl -f http://localhost:8080/health && systemctl is-active app" + only: + - schedules + retry: + max: 2 + when: script_failure diff --git a/docs/examples/jenkins-iam-keys.groovy b/docs/examples/jenkins-iam-keys.groovy new file mode 100644 index 0000000..20b9540 --- /dev/null +++ b/docs/examples/jenkins-iam-keys.groovy @@ -0,0 +1,365 @@ +// Jenkins Pipeline - IAM Keys Authentication Example +// +// This Jenkinsfile demonstrates how to use ztictl in Jenkins with IAM access keys. +// For self-hosted Jenkins on EC2, consider using EC2 instance profiles instead. +// +// Prerequisites: +// 1. Create an IAM user with programmatic access +// 2. Attach necessary permissions to the user (see docs/IAM_PERMISSIONS.md) +// 3. Store credentials in Jenkins: +// - Credentials ID: aws-access-key-id (Secret text) +// - Credentials ID: aws-secret-access-key (Secret text) +// - Optionally: production-instance-id (Secret text) +// 4. Install required plugins: +// - Pipeline plugin +// - Credentials plugin +// - Credentials Binding plugin +// +// Security Note: +// - IAM access keys are long-lived credentials (security risk) +// - Rotate keys regularly (every 90 days minimum) +// - Use EC2 instance profiles if Jenkins runs on AWS +// - Consider using OIDC if available in your Jenkins version + +pipeline { + agent any + + environment { + AWS_DEFAULT_REGION = 'ca-central-1' + ZTICTL_DEFAULT_REGION = 'ca-central-1' + ZTICTL_NON_INTERACTIVE = 'true' + ZTICTL_LOG_ENABLED = 'false' + } + + stages { + stage('Setup') { + steps { + script { + echo 'Installing ztictl...' + sh ''' + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + ztictl --version + ''' + + echo 'Installing AWS Session Manager plugin...' + sh ''' + curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" \ + -o "session-manager-plugin.deb" + sudo dpkg -i session-manager-plugin.deb + session-manager-plugin --version + ''' + } + } + } + + stage('Configure') { + steps { + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + script { + echo 'Verifying AWS credentials...' + sh 'aws sts get-caller-identity' + + echo 'Initializing ztictl configuration...' + sh 'ztictl config init --non-interactive' + + echo 'Verifying ztictl configuration...' + sh 'ztictl config check' + } + } + } + } + + stage('List Instances') { + steps { + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + script { + echo 'Listing EC2 instances...' + sh 'ztictl ssm list --region ${AWS_DEFAULT_REGION} --table' + } + } + } + } + + stage('Deploy to Production') { + when { + branch 'main' + } + steps { + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + script { + echo 'Deploying to production instance...' + sh ''' + ztictl ssm exec ${AWS_DEFAULT_REGION} web-server-prod \ + "cd /app && git pull && systemctl restart app" + ''' + } + } + } + } + + stage('Deploy to All Web Servers') { + when { + branch 'main' + } + steps { + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + script { + echo 'Deploying to all web servers...' + sh ''' + ztictl ssm exec-tagged ${AWS_DEFAULT_REGION} \ + --tags Environment=production,Service=web \ + "/app/deploy.sh" + ''' + } + } + } + } + + stage('Verify Deployment') { + when { + branch 'main' + } + steps { + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + script { + echo 'Verifying deployment...' + sh ''' + ztictl ssm exec ${AWS_DEFAULT_REGION} web-server-prod \ + "systemctl status app && curl -f http://localhost:8080/health" + ''' + } + } + } + } + } + + post { + always { + echo 'Pipeline completed' + } + success { + echo 'Deployment successful' + } + failure { + echo 'Deployment failed' + // Download logs for debugging + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY'), + string(credentialsId: 'production-instance-id', variable: 'INSTANCE_ID') + ]) { + script { + sh ''' + mkdir -p artifacts + ztictl ssm transfer download \ + ${INSTANCE_ID} \ + /var/log/app/error.log \ + ./artifacts/error.log \ + --region ${AWS_DEFAULT_REGION} || true + ''' + archiveArtifacts artifacts: 'artifacts/**', allowEmptyArchive: true + } + } + } + } +} + +// Alternative: EC2 Instance Profile Example +// Use this if Jenkins runs on an EC2 instance with an attached IAM role + +/* +pipeline { + agent { label 'ec2' } // EC2-based Jenkins agent + + environment { + AWS_DEFAULT_REGION = 'ca-central-1' + ZTICTL_DEFAULT_REGION = 'ca-central-1' + ZTICTL_NON_INTERACTIVE = 'true' + } + + stages { + stage('Setup') { + steps { + sh ''' + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + ''' + } + } + + stage('Configure') { + steps { + // No credential management needed - uses instance profile + sh 'aws sts get-caller-identity' + sh 'ztictl config init --non-interactive' + } + } + + stage('Deploy') { + steps { + sh ''' + ztictl ssm exec-tagged ${AWS_DEFAULT_REGION} \ + --tags Environment=production \ + "/app/deploy.sh" + ''' + } + } + } +} +*/ + +// Multi-Region Deployment Example + +/* +pipeline { + agent any + + parameters { + choice( + name: 'DEPLOY_REGIONS', + choices: ['ca-central-1', 'us-east-1', 'eu-west-1', 'all'], + description: 'Select deployment region(s)' + ) + } + + environment { + ZTICTL_NON_INTERACTIVE = 'true' + } + + stages { + stage('Setup') { + steps { + sh ''' + curl -L https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux-amd64 -o ztictl + chmod +x ztictl + sudo mv ztictl /usr/local/bin/ + ''' + } + } + + stage('Deploy Multi-Region') { + steps { + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + script { + def regions = params.DEPLOY_REGIONS == 'all' ? + ['ca-central-1', 'us-east-1', 'eu-west-1'] : + [params.DEPLOY_REGIONS] + + regions.each { region -> + echo "Deploying to ${region}..." + sh """ + export AWS_DEFAULT_REGION=${region} + export ZTICTL_DEFAULT_REGION=${region} + ztictl config init --non-interactive + ztictl ssm exec-tagged ${region} \ + --tags Environment=production \ + "/app/deploy.sh" + """ + } + } + } + } + } + } +} +*/ + +// Test Environment Management Example + +/* +pipeline { + agent any + + environment { + AWS_DEFAULT_REGION = 'ca-central-1' + ZTICTL_DEFAULT_REGION = 'ca-central-1' + ZTICTL_NON_INTERACTIVE = 'true' + } + + stages { + stage('Start Test Instances') { + steps { + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + sh ''' + ztictl ssm start-tagged \ + --region ${AWS_DEFAULT_REGION} \ + --tags Environment=test,AutoStart=true + echo "Waiting for instances to be ready..." + sleep 60 + ''' + } + } + } + + stage('Run Tests') { + steps { + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + sh ''' + ztictl ssm exec-tagged ${AWS_DEFAULT_REGION} \ + --tags Environment=test \ + "cd /app && npm test" + ''' + } + } + } + + stage('Stop Test Instances') { + steps { + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + sh ''' + ztictl ssm stop-tagged \ + --region ${AWS_DEFAULT_REGION} \ + --tags Environment=test + ''' + } + } + } + } + + post { + always { + // Always stop test instances, even if tests fail + withCredentials([ + string(credentialsId: 'aws-access-key-id', variable: 'AWS_ACCESS_KEY_ID'), + string(credentialsId: 'aws-secret-access-key', variable: 'AWS_SECRET_ACCESS_KEY') + ]) { + sh ''' + ztictl ssm stop-tagged \ + --region ${AWS_DEFAULT_REGION} \ + --tags Environment=test || true + ''' + } + } + } +} +*/ diff --git a/ztictl/README.md b/ztictl/README.md index 640686a..8abc8d9 100644 --- a/ztictl/README.md +++ b/ztictl/README.md @@ -110,8 +110,113 @@ See [Fuzzy Finder Features](docs/FUZZY_FINDER_FEATURES.md) for complete keyboard - **[Command Reference](../docs/COMMANDS.md)** - All commands with detailed examples - **[Configuration Guide](../docs/CONFIGURATION.md)** - Setup and configuration options - **[Multi-Region Operations](../docs/MULTI_REGION.md)** - Cross-region execution guide +- **[CI/CD Authentication](../docs/CI_CD_AUTHENTICATION.md)** - Using ztictl in CI/CD pipelines - **[Troubleshooting](../docs/TROUBLESHOOTING.md)** - Common issues and solutions +## Authentication Methods + +`ztictl` supports multiple authentication methods depending on your environment: + +### Interactive Development (AWS SSO) + +For local development and manual operations: + +```bash +# AWS SSO authentication (requires browser) +ztictl auth login + +# Check current credentials +ztictl auth whoami +``` + +**Best for:** +- Local development +- Manual operations +- Multi-account access with role switching + +**Requirements:** +- AWS SSO configured +- Browser access for authentication +- Interactive terminal + +### CI/CD Pipelines (IAM-based) + +For automated pipelines, AWS SSO **cannot be used** (requires browser interaction). Use IAM-based authentication instead: + +| Method | When to Use | Security | Setup Complexity | +|--------|-------------|----------|------------------| +| **OIDC Federation** | GitHub Actions, GitLab CI, modern platforms | ⭐⭐⭐⭐⭐ Best | Medium | +| **EC2 Instance Profile** | Self-hosted runners on EC2 | ⭐⭐⭐⭐⭐ Best | Easy | +| **ECS Task Role** | Containerized CI/CD on ECS/Fargate | ⭐⭐⭐⭐⭐ Best | Easy | +| **IAM Access Keys** | Legacy systems, quick testing | ⭐⭐ Poor | Easy | + +**OIDC Federation Example (Recommended):** +```yaml +# GitHub Actions +- name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole + aws-region: ca-central-1 + +- name: Use ztictl + run: | + ztictl config init --non-interactive + ztictl ssm exec-multi --tag Environment=prod --command "deploy.sh" +``` + +**EC2 Instance Profile Example:** +```bash +# No credential configuration needed - automatic from instance metadata +ztictl config init --non-interactive +ztictl ssm list --table +``` + +**IAM Access Keys Example (Not Recommended):** +```bash +# Set environment variables (store in CI/CD secrets) +export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE" +export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +export AWS_DEFAULT_REGION="ca-central-1" + +ztictl config init --non-interactive +ztictl ssm list --table +``` + +### Non-Interactive Mode + +`ztictl` automatically detects CI/CD environments and enables non-interactive mode when: +- `CI` environment variable is set (most platforms set this automatically) +- `ZTICTL_NON_INTERACTIVE=true` is set +- `--non-interactive` flag is used + +**In non-interactive mode:** +- No splash screen or prompts +- Commands requiring instance selection will fail with clear error messages +- Use explicit instance IDs or tag-based commands + +```bash +# These commands work in CI/CD (no interactive prompts) +ztictl ssm list --table +ztictl ssm exec i-1234567890abcdef0 --command "uptime" +ztictl ssm exec-multi --tag Environment=prod --command "deploy.sh" +ztictl ssm power start --tag Environment=test + +# These require interactive selection (will fail in CI/CD) +ztictl ssm connect # ❌ No instance ID specified +ztictl ssm exec "uptime" # ❌ No instance ID specified + +# Fix: Provide instance identifier +ztictl ssm connect i-1234567890abcdef0 # ✅ Works +ztictl ssm connect web-server-prod # ✅ Works (name lookup) +``` + +**Complete Guide:** See [docs/CI_CD_AUTHENTICATION.md](../docs/CI_CD_AUTHENTICATION.md) for: +- Detailed authentication setup for each platform +- IAM permission requirements +- Complete workflow examples +- Troubleshooting + ## Core Operations ### Quick Examples diff --git a/ztictl/cmd/ztictl/auth.go b/ztictl/cmd/ztictl/auth.go index 501ed8f..f8a3809 100644 --- a/ztictl/cmd/ztictl/auth.go +++ b/ztictl/cmd/ztictl/auth.go @@ -31,11 +31,57 @@ var authLoginCmd = &cobra.Command{ Use: "login [profile]", Short: "Login to AWS SSO", Long: `Login to AWS SSO with interactive account and role selection. -A profile name must be specified to ensure intentional credential management.`, +A profile name must be specified to ensure intentional credential management. + +Note: AWS SSO authentication requires browser interaction and cannot be used in CI/CD pipelines. +For automated environments, use IAM-based authentication (OIDC, EC2 instance profiles, or IAM access keys). +See docs/CI_CD_AUTHENTICATION.md for details.`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { profileName := args[0] + // Check if running in non-interactive mode (CI/CD environment) + execCtx := GetExecutionContext(cmd) + if execCtx.NonInteractive || execCtx.IsCI { + // Check if AWS credentials are already configured + hasCredentials, credType := auth.DetectEnvironmentCredentials() + + if hasCredentials { + logging.LogError("\nDetected CI/CD environment with %s\n", credType) + fmt.Fprintf(os.Stderr, ` +AWS SSO authentication requires browser interaction and cannot be used in CI/CD pipelines. + +Your environment already has AWS credentials configured. +Use ztictl commands directly without 'auth login'. + +For more information, see: docs/CI_CD_AUTHENTICATION.md +`) + os.Exit(1) + } else { + logging.LogError("\nDetected CI/CD environment without AWS credentials\n") + fmt.Fprintf(os.Stderr, ` +AWS SSO authentication requires browser interaction and cannot be used in CI/CD pipelines. + +To authenticate in CI/CD, you must configure IAM-based authentication: + - GitHub Actions: Use OIDC federation + - GitLab CI: Use OIDC federation + - Jenkins: Use IAM access keys or EC2 instance profiles + - Other platforms: See docs/CI_CD_AUTHENTICATION.md + +Example GitHub Actions workflow: + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ca-central-1 + +For complete examples and setup instructions: + https://github.com/zsoftly/ztiaws/blob/main/docs/CI_CD_AUTHENTICATION.md +`) + os.Exit(1) + } + } + if err := performLogin(profileName); err != nil { logging.LogError("Login failed: %v", err) os.Exit(1) diff --git a/ztictl/cmd/ztictl/config.go b/ztictl/cmd/ztictl/config.go index 363efe8..5b519d1 100644 --- a/ztictl/cmd/ztictl/config.go +++ b/ztictl/cmd/ztictl/config.go @@ -32,12 +32,16 @@ var configInitCmd = &cobra.Command{ Use: "init", Short: "Initialize configuration", Long: `Initialize ztictl configuration by creating a configuration file. -This will guide you through an interactive setup process to configure AWS SSO settings.`, +This will guide you through an interactive setup process to configure AWS SSO settings. + +In non-interactive mode (CI/CD), configuration can be created using environment variables. +See docs/CI_CD_AUTHENTICATION.md for details.`, Run: func(cmd *cobra.Command, args []string) { force, _ := cmd.Flags().GetBool("force") interactive, _ := cmd.Flags().GetBool("interactive") + execCtx := GetExecutionContext(cmd) - if err := initializeConfigFile(force, interactive); err != nil { + if err := initializeConfigFile(force, interactive, execCtx); err != nil { logger.Error("Configuration initialization failed", "error", err) os.Exit(1) } @@ -136,7 +140,7 @@ This command will identify invalid values and prompt you for correct replacement } // initializeConfigFile handles the config initialization logic and returns errors instead of calling os.Exit -func initializeConfigFile(force, interactive bool) error { +func initializeConfigFile(force, interactive bool, execCtx *ExecutionContext) error { // Determine config file path home, err := os.UserHomeDir() if err != nil { @@ -149,19 +153,30 @@ func initializeConfigFile(force, interactive bool) error { if _, err := os.Stat(configPath); err == nil { // File exists - check if we should overwrite if !force { - fmt.Printf("\n⚠️ Configuration file already exists at %s\n", configPath) - fmt.Println("Would you like to overwrite it? (yes/no)") + // In non-interactive mode, require --force flag + if execCtx != nil && execCtx.NonInteractive { + return fmt.Errorf("config file exists at %s, use --force to overwrite in non-interactive mode", configPath) + } - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(strings.ToLower(response)) + // Auto-yes mode - proceed without prompting + if execCtx != nil && execCtx.AutoYes { + force = true + } else { + // Interactive mode - ask user + fmt.Printf("\n⚠️ Configuration file already exists at %s\n", configPath) + fmt.Println("Would you like to overwrite it? (yes/no)") - if response != "yes" && response != "y" { - fmt.Println("Configuration initialization cancelled.") - return nil + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + + if response != "yes" && response != "y" { + fmt.Println("Configuration initialization cancelled.") + return nil + } + // User confirmed, proceed as if --force was provided + force = true } - // User confirmed, proceed as if --force was provided - force = true } // Run interactive setup if force is now true (either from flag or user confirmation) @@ -181,7 +196,15 @@ func initializeConfigFile(force, interactive bool) error { return nil } - // Create sample configuration (non-interactive) + // In non-interactive mode, create config from environment variables + if execCtx != nil && execCtx.NonInteractive { + if err := runNonInteractiveConfig(configPath); err != nil { + return fmt.Errorf("non-interactive configuration failed: %w", err) + } + return nil + } + + // Create sample configuration (default non-interactive, no env vars) if err := config.CreateSampleConfig(configPath); err != nil { return fmt.Errorf("failed to create configuration file: %w", err) } diff --git a/ztictl/cmd/ztictl/config_test.go b/ztictl/cmd/ztictl/config_test.go index 52fb387..139b8c3 100644 --- a/ztictl/cmd/ztictl/config_test.go +++ b/ztictl/cmd/ztictl/config_test.go @@ -179,7 +179,7 @@ func TestInitializeConfigFile(t *testing.T) { } // The function should return an error or succeed, not call os.Exit - err := initializeConfigFile(false, false) + err := initializeConfigFile(false, false, nil) // We expect this might fail (no config creation), but it shouldn't panic // The important thing is that it returns an error instead of calling os.Exit diff --git a/ztictl/cmd/ztictl/root.go b/ztictl/cmd/ztictl/root.go index 198adcb..199a4c4 100644 --- a/ztictl/cmd/ztictl/root.go +++ b/ztictl/cmd/ztictl/root.go @@ -2,11 +2,13 @@ package main import ( "bufio" + "context" "errors" "fmt" "os" "path/filepath" "runtime" + "strconv" "strings" "ztictl/internal/config" @@ -22,13 +24,89 @@ var ( // Version represents the current version of ztictl // This can be set at build time using -ldflags "-X main.version=X.Y.Z" // Default version is "2.5.0"; override at build time with -ldflags "-X main.Version=X.Y.Z" - Version = "2.10.0" - configFile string - debug bool - showSplash bool - logger *logging.Logger + Version = "2.10.0" + configFile string + debug bool + showSplash bool + nonInteractive bool + autoYes bool + logger *logging.Logger ) +// ExecutionContext contains runtime execution context +type ExecutionContext struct { + NonInteractive bool // Disable all interactive prompts + AutoYes bool // Automatically answer yes to confirmations + IsCI bool // Detected CI/CD environment +} + +// Context key for storing execution context +type contextKey string + +const execContextKey contextKey = "execContext" + +// detectCIEnvironment checks if running in a CI/CD environment +func detectCIEnvironment() bool { + ciEnvVars := []string{ + "CI", // Generic CI indicator (Travis, CircleCI, GitLab, etc.) + "GITHUB_ACTIONS", // GitHub Actions + "GITLAB_CI", // GitLab CI + "JENKINS_HOME", // Jenkins + "JENKINS_URL", // Jenkins + "CIRCLECI", // CircleCI + "TRAVIS", // Travis CI + "BUILDKITE", // Buildkite + "DRONE", // Drone CI + "TF_BUILD", // Azure Pipelines + "CODEBUILD_BUILD_ID", // AWS CodeBuild + } + + for _, envVar := range ciEnvVars { + if os.Getenv(envVar) != "" { + return true + } + } + + return false +} + +// createExecutionContext creates the execution context based on flags and environment +func createExecutionContext() *ExecutionContext { + isCI := detectCIEnvironment() + + // Non-interactive mode is enabled if: + // 1. --non-interactive flag is set, OR + // 2. ZTICTL_NON_INTERACTIVE=true, OR + // 3. CI environment is detected + nonInteractiveMode := nonInteractive || + os.Getenv("ZTICTL_NON_INTERACTIVE") == "true" || + isCI + + return &ExecutionContext{ + NonInteractive: nonInteractiveMode, + AutoYes: autoYes, + IsCI: isCI, + } +} + +// GetExecutionContext retrieves the execution context from a command +func GetExecutionContext(cmd *cobra.Command) *ExecutionContext { + if cmd == nil { + return &ExecutionContext{} + } + + ctx := cmd.Context() + if ctx == nil { + return &ExecutionContext{} + } + + if execCtx, ok := ctx.Value(execContextKey).(*ExecutionContext); ok { + return execCtx + } + + return &ExecutionContext{} +} + // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "ztictl", @@ -54,6 +132,18 @@ Features: _ = cmd.Help() }, PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Create and store execution context + execCtx := createExecutionContext() + + // Get parent context, use Background if nil (e.g., in tests) + parentCtx := cmd.Context() + if parentCtx == nil { + parentCtx = context.Background() + } + + ctx := context.WithValue(parentCtx, execContextKey, execCtx) + cmd.SetContext(ctx) + // Skip splash for help, version, and completion commands if cmd.Name() == "help" || cmd.Name() == "version" || cmd.Name() == cobra.ShellCompRequestCmd || cmd.Parent() == nil { return @@ -82,7 +172,7 @@ Features: _ = os.Rename(tempFile, versionFile) // Ignore error as restore is optional } } else { - // Normal splash behavior + // Normal splash behavior (automatically skipped in CI by splash.ShowSplash) showedSplash, err = splash.ShowSplash(Version) } @@ -92,7 +182,8 @@ Features: } // If this is the first run, show helpful message instead of automatic setup - if showedSplash { + // Skip in non-interactive mode + if showedSplash && !execCtx.NonInteractive { cfg := config.Get() if cfg != nil && cfg.SSO.StartURL == "" { fmt.Println("\n🚀 Welcome to ztictl!") @@ -117,6 +208,8 @@ func init() { rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.ztictl.yaml)") rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug output") rootCmd.PersistentFlags().BoolVar(&showSplash, "show-splash", false, "force display of welcome splash screen") + rootCmd.PersistentFlags().BoolVar(&nonInteractive, "non-interactive", false, "disable all interactive prompts (fail with error if input required)") + rootCmd.PersistentFlags().BoolVarP(&autoYes, "yes", "y", false, "automatically answer yes to all confirmation prompts") // Bind flags to viper _ = viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")) // #nosec G104 @@ -362,6 +455,119 @@ region_shortcuts: return nil } +// runNonInteractiveConfig creates configuration from environment variables +func runNonInteractiveConfig(configPath string) error { + // Get values from environment variables with fallback to sensible defaults + defaultRegion := getEnvOrDefault("ZTICTL_DEFAULT_REGION", "ca-central-1") + + // SSO configuration (optional - CI/CD uses IAM auth, not SSO) + ssoStartURL := os.Getenv("ZTICTL_SSO_START_URL") + if ssoStartURL == "" { + // If domain ID is provided, construct the URL + if domainID := os.Getenv("ZTICTL_SSO_DOMAIN_ID"); domainID != "" { + ssoStartURL = fmt.Sprintf("https://%s.awsapps.com/start", domainID) + } + } + ssoRegion := getEnvOrDefault("ZTICTL_SSO_REGION", "ca-central-1") + + // Get home directory for log paths + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("unable to get home directory: %w", err) + } + + // Logging configuration - default to disabled in CI/CD + logDir := getEnvOrDefault("ZTICTL_LOG_DIR", filepath.Join(home, "logs")) + logDir = filepath.ToSlash(logDir) + fileLogging := getEnvBoolOrDefault("ZTICTL_LOG_ENABLED", false) + logLevel := getEnvOrDefault("ZTICTL_LOG_LEVEL", "info") + + // System configuration + tempDir := getEnvOrDefault("ZTICTL_TEMP_DIR", os.TempDir()) + tempDir = filepath.ToSlash(tempDir) + iamDelay := getEnvIntOrDefault("ZTICTL_IAM_DELAY", 5) + s3Prefix := getEnvOrDefault("ZTICTL_S3_PREFIX", "") + + // Build config content + configContent := fmt.Sprintf(`# ztictl Configuration File +# Generated in non-interactive mode from environment variables + +# AWS SSO Configuration (optional in CI/CD) +sso: + start_url: "%s" + region: "%s" + +# Default AWS region for operations +default_region: "%s" + +# Logging configuration +logging: + directory: "%s" + file_logging: %t + level: "%s" + +# System configuration +system: + session_manager_plugin_path: "" # Auto-detected if empty + temp_directory: "%s" + iam_propagation_delay: %d + s3_bucket_prefix: "%s" + +# Region shortcuts for convenience +region_shortcuts: + cac1: "ca-central-1" + use1: "us-east-1" + use2: "us-east-2" + usw1: "us-west-1" + usw2: "us-west-2" + euw1: "eu-west-1" + euw2: "eu-west-2" + euc1: "eu-central-1" + apne1: "ap-northeast-1" + apne2: "ap-northeast-2" + apse1: "ap-southeast-1" + apse2: "ap-southeast-2" + aps1: "ap-south-1" +`, ssoStartURL, ssoRegion, defaultRegion, logDir, fileLogging, logLevel, tempDir, iamDelay, s3Prefix) + + // Write configuration file + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + return fmt.Errorf("failed to write configuration file: %w", err) + } + + logger.Info("Configuration created from environment variables", "path", configPath) + return nil +} + +// getEnvOrDefault gets environment variable or returns default value +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getEnvBoolOrDefault gets boolean environment variable or returns default +func getEnvBoolOrDefault(key string, defaultValue bool) bool { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value == "true" || value == "1" || value == "yes" +} + +// getEnvIntOrDefault gets integer environment variable or returns default +func getEnvIntOrDefault(key string, defaultValue int) int { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + return defaultValue +} + // isValidAWSRegion checks if a region string follows AWS region format func isValidAWSRegion(region string) bool { // AWS region format: {area}-{subarea}-{number} diff --git a/ztictl/cmd/ztictl/ssm_power.go b/ztictl/cmd/ztictl/ssm_power.go index 5ff1191..e6b4b30 100644 --- a/ztictl/cmd/ztictl/ssm_power.go +++ b/ztictl/cmd/ztictl/ssm_power.go @@ -168,9 +168,12 @@ Examples: return } + // Create SSM manager for validation + ssmManager := ssm.NewManager(logger) + // Execute power operations in parallel startTime := time.Now() - results := executePowerOperationParallel(ctx, awsClient, instanceIDs, "start", parallelFlag, region) + results := executePowerOperationParallel(ctx, awsClient, ssmManager, instanceIDs, "start", parallelFlag, region) totalDuration := time.Since(startTime) // Process and display results @@ -246,9 +249,12 @@ Examples: return } + // Create SSM manager for validation + ssmManager := ssm.NewManager(logger) + // Execute power operations in parallel startTime := time.Now() - results := executePowerOperationParallel(ctx, awsClient, instanceIDs, "stop", parallelFlag, region) + results := executePowerOperationParallel(ctx, awsClient, ssmManager, instanceIDs, "stop", parallelFlag, region) totalDuration := time.Since(startTime) // Process and display results @@ -324,9 +330,12 @@ Examples: return } + // Create SSM manager for validation + ssmManager := ssm.NewManager(logger) + // Execute power operations in parallel startTime := time.Now() - results := executePowerOperationParallel(ctx, awsClient, instanceIDs, "reboot", parallelFlag, region) + results := executePowerOperationParallel(ctx, awsClient, ssmManager, instanceIDs, "reboot", parallelFlag, region) totalDuration := time.Since(startTime) // Process and display results @@ -367,8 +376,11 @@ func performPowerOperation(args []string, regionCode, instancesFlag string, para return fmt.Errorf("failed to create AWS client: %w", err) } + // Create SSM manager for validation + ssmManager := ssm.NewManager(logger) + startTime := time.Now() - results := executePowerOperationParallel(ctx, awsClient, instanceIDs, operation, parallelFlag, region) + results := executePowerOperationParallel(ctx, awsClient, ssmManager, instanceIDs, operation, parallelFlag, region) totalDuration := time.Since(startTime) return displayPowerOperationResults(results, operation, totalDuration, parallelFlag) } @@ -394,15 +406,9 @@ func performPowerOperation(args []string, regionCode, instancesFlag string, para logging.LogInfo("%s instance %s in region: %s", capitalize(operation), instanceID, region) // Validate instance state before attempting power operation - requirements := InstanceValidationRequirements{ - RequireSSMOnline: false, - Operation: operation, - } - switch operation { - case "start": - requirements.AllowedStates = []string{"stopped"} - case "stop", "reboot": - requirements.AllowedStates = []string{"running"} + requirements, err := buildRequirementsForOperation(operation) + if err != nil { + return err } if err := ValidateInstanceState(ctx, ssmManager, instanceID, region, requirements); err != nil { return err @@ -462,6 +468,23 @@ func validateTaggedCommandArgs(tagsFlag, instancesFlag string, parallelFlag int) return nil } +// buildRequirementsForOperation creates validation requirements for a power operation +func buildRequirementsForOperation(operation string) (InstanceValidationRequirements, error) { + req := InstanceValidationRequirements{ + RequireSSMOnline: false, + Operation: operation, + } + switch operation { + case "start": + req.AllowedStates = []string{"stopped"} + case "stop", "reboot": + req.AllowedStates = []string{"running"} + default: + return req, fmt.Errorf("unknown operation: %s", operation) + } + return req, nil +} + // capitalize returns a copy of the string with the first letter capitalized func capitalize(s string) string { if s == "" { @@ -509,10 +532,11 @@ func getInstanceIDsByTags(ctx context.Context, awsClient *aws.Client, tagsFlag s } // executePowerOperationParallel runs power operations in parallel across multiple instances -func executePowerOperationParallel(ctx context.Context, awsClient *aws.Client, instanceIDs []string, operation string, maxParallel int, region string) []PowerOperationResult { - ssmManager := ssm.NewManager(logger) - +func executePowerOperationParallel(ctx context.Context, awsClient *aws.Client, ssmManager *ssm.Manager, instanceIDs []string, operation string, maxParallel int, region string) []PowerOperationResult { // Create channels for work distribution and result collection + // Buffers sized to instance count for simplicity - memory scales linearly with instance count. + // For typical operations (< 1000 instances), memory overhead is negligible (~100KB). + // For very large deployments (> 10000 instances), consider refactoring to smaller buffers. instanceChan := make(chan string, len(instanceIDs)) resultChan := make(chan PowerOperationResult, len(instanceIDs)) @@ -532,21 +556,8 @@ func executePowerOperationParallel(ctx context.Context, awsClient *aws.Client, i startTime := time.Now() logging.LogInfo("Executing %s operation on instance %s", operation, instanceID) - var err error - // Validate instance state before attempting operation - requirements := InstanceValidationRequirements{ - RequireSSMOnline: false, - Operation: operation, - } - switch operation { - case "start": - requirements.AllowedStates = []string{"stopped"} - case "stop", "reboot": - requirements.AllowedStates = []string{"running"} - default: - err = fmt.Errorf("unknown operation: %s", operation) - } + requirements, err := buildRequirementsForOperation(operation) // Only proceed with validation and operation if no error yet if err == nil { @@ -568,6 +579,8 @@ func executePowerOperationParallel(ctx context.Context, awsClient *aws.Client, i _, err = awsClient.EC2.RebootInstances(ctx, &ec2.RebootInstancesInput{ InstanceIds: []string{instanceID}, }) + default: + err = fmt.Errorf("unknown operation: %s", operation) } } diff --git a/ztictl/internal/auth/sso.go b/ztictl/internal/auth/sso.go index b1d52ea..862482a 100644 --- a/ztictl/internal/auth/sso.go +++ b/ztictl/internal/auth/sso.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io/fs" + "net/http" "os" "path/filepath" "runtime" @@ -100,6 +101,89 @@ type Role struct { AccountID string `json:"account_id"` } +// CredentialType represents the type of AWS credentials detected +type CredentialType string + +const ( + // CredentialTypeNone indicates no credentials detected + CredentialTypeNone CredentialType = "none" + // CredentialTypeAccessKeys indicates IAM access keys in environment + CredentialTypeAccessKeys CredentialType = "IAM access keys" + // CredentialTypeEC2Instance indicates EC2 instance profile + CredentialTypeEC2Instance CredentialType = "EC2 instance profile" // #nosec G101 + // CredentialTypeECSTask indicates ECS task role + CredentialTypeECSTask CredentialType = "ECS task role" // #nosec G101 + // CredentialTypeOIDC indicates OIDC assumed role + CredentialTypeOIDC CredentialType = "OIDC assumed role" +) + +// DetectEnvironmentCredentials checks for AWS credentials in the environment +// Returns true if credentials are found, along with the credential type +func DetectEnvironmentCredentials() (bool, CredentialType) { + // Check for IAM access keys in environment variables + if os.Getenv("AWS_ACCESS_KEY_ID") != "" { + return true, CredentialTypeAccessKeys + } + + // Check for ECS task role + // ECS provides credentials via a relative URI + if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" { + return true, CredentialTypeECSTask + } + + // Check for OIDC assumed role + // When using OIDC (GitHub Actions, GitLab CI, etc.), AWS_ROLE_ARN is set + if os.Getenv("AWS_ROLE_ARN") != "" { + return true, CredentialTypeOIDC + } + + // Check for EC2 instance metadata (IMDS) + if isEC2Instance() { + return true, CredentialTypeEC2Instance + } + + return false, CredentialTypeNone +} + +// isEC2Instance checks if running on an EC2 instance by querying IMDS +// Uses a short timeout to avoid blocking if not on EC2 +func isEC2Instance() bool { + // Create HTTP client with very short timeout + client := &http.Client{ + Timeout: 100 * time.Millisecond, + } + + // Try to reach EC2 instance metadata service (IMDSv2 token endpoint) + // Using the token endpoint because it's available on both IMDSv1 and IMDSv2 + req, err := http.NewRequest("PUT", "http://169.254.169.254/latest/api/token", nil) + if err != nil { + return false + } + + // IMDSv2 requires this header + req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "1") + + resp, err := client.Do(req) + if err != nil { + // Also try IMDSv1 as fallback (simple GET request) + req, err = http.NewRequest("GET", "http://169.254.169.254/latest/meta-data/", nil) + if err != nil { + return false + } + + resp, err = client.Do(req) + if err != nil { + return false + } + } + + defer resp.Body.Close() + + // If we get a 200 or 404, we're on EC2 + // 404 can happen on IMDSv1 with certain endpoints + return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound +} + // NewManager creates a new authentication manager with a no-op logger func NewManager() *Manager { return &Manager{ diff --git a/ztictl/internal/splash/splash.go b/ztictl/internal/splash/splash.go index f28401e..6f5e329 100644 --- a/ztictl/internal/splash/splash.go +++ b/ztictl/internal/splash/splash.go @@ -10,6 +10,7 @@ import ( "ztictl/pkg/security" "github.com/fatih/color" + "golang.org/x/term" ) const ( @@ -42,8 +43,44 @@ type SplashConfig struct { IsNewVersion bool } +// isCI detects if running in a CI/CD environment +func isCI() bool { + ciEnvVars := []string{ + "CI", // Generic CI indicator + "GITHUB_ACTIONS", // GitHub Actions + "GITLAB_CI", // GitLab CI + "JENKINS_HOME", // Jenkins + "JENKINS_URL", // Jenkins + "CIRCLECI", // CircleCI + "TRAVIS", // Travis CI + "BUILDKITE", // Buildkite + "DRONE", // Drone CI + "TF_BUILD", // Azure Pipelines + "CODEBUILD_BUILD_ID", // AWS CodeBuild + "ZTICTL_NON_INTERACTIVE", // Explicit non-interactive mode + } + + for _, envVar := range ciEnvVars { + if os.Getenv(envVar) != "" { + return true + } + } + + return false +} + +// isTerminal checks if stdin is connected to a terminal +func isTerminal() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} + // ShowSplash displays the welcome splash screen if appropriate func ShowSplash(version string) (bool, error) { + // Never show splash in CI/CD environments or when not connected to a terminal + if isCI() || !isTerminal() { + return false, nil + } + homeDir, err := os.UserHomeDir() if err != nil { return false, fmt.Errorf("failed to get home directory: %w", err) @@ -56,12 +93,13 @@ func ShowSplash(version string) (bool, error) { return false, fmt.Errorf("invalid version file path: %w", err) } - // Check if this is first run or new version + // Check if this is first run isFirstRun := false - isNewVersion := false + shouldUpdateVersionFile := false if _, err := os.Stat(versionFile); os.IsNotExist(err) { isFirstRun = true + shouldUpdateVersionFile = true } else { // Read the last version (path already validated above) lastVersionBytes, err := os.ReadFile(versionFile) // #nosec G304 @@ -71,18 +109,18 @@ func ShowSplash(version string) (bool, error) { lastVersion := strings.TrimSpace(string(lastVersionBytes)) if lastVersion != version { - isNewVersion = true + shouldUpdateVersionFile = true } } - // Show splash if first run or new version - if isFirstRun || isNewVersion { + // Show splash ONLY on first installation + if isFirstRun { config := SplashConfig{ AppVersion: version, AppName: "ztictl", Description: "Unified AWS SSO & Systems Manager CLI", - IsFirstRun: isFirstRun, - IsNewVersion: isNewVersion, + IsFirstRun: true, + IsNewVersion: false, Features: []string{ "⚡ EC2 Power Management - Start/Stop/Reboot instances individually or in bulk", "🏷️ Advanced Tag-Based Operations - Target multiple instances with flexible filtering", @@ -96,16 +134,17 @@ func ShowSplash(version string) (bool, error) { } displaySplash(config) + shouldUpdateVersionFile = true + } - // Update version tracking file + // Update version tracking file silently for version tracking + if shouldUpdateVersionFile { if err := os.WriteFile(versionFile, []byte(version), 0600); err != nil { return false, fmt.Errorf("failed to write version file: %w", err) } - - return true, nil } - return false, nil + return isFirstRun, nil } // displaySplash renders the colored splash screen @@ -131,15 +170,10 @@ func displaySplash(config SplashConfig) { _, _ = versionColor.Printf("v%s\n", config.AppVersion) // #nosec G104 _, _ = descColor.Printf(" %s\n\n", config.Description) // #nosec G104 - // Welcome message - if config.IsFirstRun { - _, _ = headerColor.Println(" 🎉 Welcome to ztictl!") // #nosec G104 - _, _ = descColor.Println(" Small commands, powerful AWS transformations.") // #nosec G104 - fmt.Println(" Let's get you set up with everything you need.") - } else if config.IsNewVersion { - _, _ = headerColor.Printf(" ✨ ztictl v%s is ready!\n", config.AppVersion) // #nosec G104 - _, _ = descColor.Println(" Small updates, big improvements.") // #nosec G104 - } + // Welcome message (splash only shown on first installation) + _, _ = headerColor.Println(" 🎉 Welcome to ztictl!") // #nosec G104 + _, _ = descColor.Println(" Small commands, powerful AWS transformations.") // #nosec G104 + fmt.Println(" Let's get you set up with everything you need.") // Feature showcase fmt.Println() @@ -155,24 +189,17 @@ func displaySplash(config SplashConfig) { _, _ = headerColor.Println(" 🚀 Quick Start Guide:") // #nosec G104 _, _ = headerColor.Println(" " + strings.Repeat("═", 25)) // #nosec G104 - if config.IsFirstRun { - _, _ = accentColor.Println(" 1. Configure your settings:") // #nosec G104 - fmt.Println(" ztictl config init") - fmt.Println() - _, _ = accentColor.Println(" 2. Check system requirements:") // #nosec G104 - fmt.Println(" ztictl config check") - fmt.Println() - _, _ = accentColor.Println(" 3. Authenticate with AWS SSO:") // #nosec G104 - fmt.Println(" ztictl auth login") - fmt.Println() - _, _ = accentColor.Println(" 4. List your EC2 instances:") // #nosec G104 - fmt.Println(" ztictl ssm list") - } else { - _, _ = accentColor.Println(" • View help: ztictl --help") // #nosec G104 - _, _ = accentColor.Println(" • Check configuration: ztictl config show") // #nosec G104 - _, _ = accentColor.Println(" • Login to AWS: ztictl auth login") // #nosec G104 - _, _ = accentColor.Println(" • List instances: ztictl ssm list") // #nosec G104 - } + _, _ = accentColor.Println(" 1. Configure your settings:") // #nosec G104 + fmt.Println(" ztictl config init") + fmt.Println() + _, _ = accentColor.Println(" 2. Check system requirements:") // #nosec G104 + fmt.Println(" ztictl config check") + fmt.Println() + _, _ = accentColor.Println(" 3. Authenticate with AWS SSO:") // #nosec G104 + fmt.Println(" ztictl auth login") + fmt.Println() + _, _ = accentColor.Println(" 4. List your EC2 instances:") // #nosec G104 + fmt.Println(" ztictl ssm list") // Footer fmt.Println() @@ -187,15 +214,15 @@ func displaySplash(config SplashConfig) { animateMessage(" 🎯" + strings.Repeat("═", 56) + "🎯") fmt.Println() - // Pause for user to read - if config.IsFirstRun { - _, _ = headerColor.Print(" 🚀 Press Enter to continue...") // #nosec G104 - _, _ = fmt.Scanln() // Ignore error as user input is not critical - } else { - time.Sleep(3 * time.Second) - _, _ = descColor.Println(" 🚀 Starting ztictl...") // #nosec G104 - time.Sleep(1 * time.Second) + // Pause for user to read (skip in CI/CD environments) + if isCI() || !isTerminal() { + // Non-interactive mode - skip all pauses and prompts + return } + + // Prompt user to continue (splash only shown on first installation) + _, _ = headerColor.Print(" 🚀 Press Enter to continue...") // #nosec G104 + _, _ = fmt.Scanln() // Ignore error as user input is not critical } // animateMessage displays a message with a simple animation effect diff --git a/ztictl/internal/splash/splash_test.go b/ztictl/internal/splash/splash_test.go index 44988fb..9b45365 100644 --- a/ztictl/internal/splash/splash_test.go +++ b/ztictl/internal/splash/splash_test.go @@ -28,34 +28,35 @@ func TestShowSplash(t *testing.T) { _ = os.Setenv("USERPROFILE", tempDir) // #nosec G104 } - // Test first run - should return true (splash shown) + // When running tests, there's no terminal attached, so splash should not be shown + // This tests that the function correctly detects non-terminal environments shown, err := ShowSplash("2.1.0-test") if err != nil { t.Fatalf("ShowSplash failed: %v", err) } - if !shown { - t.Error("Expected splash to be shown on first run") + if shown { + t.Error("Expected splash NOT to be shown in non-terminal environment (test)") } - // Test subsequent run with same version - should return false + // Verify it consistently returns false in non-terminal environments shown, err = ShowSplash("2.1.0-test") if err != nil { t.Fatalf("ShowSplash failed on second call: %v", err) } if shown { - t.Error("Expected splash NOT to be shown on subsequent run with same version") + t.Error("Expected splash NOT to be shown on subsequent run in non-terminal environment") } - // Test with new version - should return true + // Even with a new version, splash should not be shown in non-terminal environments shown, err = ShowSplash("2.2.0-test") if err != nil { t.Fatalf("ShowSplash failed with new version: %v", err) } - if !shown { - t.Error("Expected splash to be shown with new version") + if shown { + t.Error("Expected splash NOT to be shown with new version in non-terminal environment") } } diff --git a/ztictl/internal/ssm/manager.go b/ztictl/internal/ssm/manager.go index 4f59336..47f4115 100644 --- a/ztictl/internal/ssm/manager.go +++ b/ztictl/internal/ssm/manager.go @@ -177,6 +177,7 @@ func (m *Manager) initializeManagers(ctx context.Context, region string) error { m.mu.Lock() defer m.mu.Unlock() + // Check if already initialized (inside lock to prevent race condition) if m.iamManager != nil && m.s3LifecycleManager != nil { return nil } diff --git a/ztictl/pkg/version/checker.go b/ztictl/pkg/version/checker.go index e82e841..a1403ef 100644 --- a/ztictl/pkg/version/checker.go +++ b/ztictl/pkg/version/checker.go @@ -1,10 +1,12 @@ package version import ( + "crypto/tls" "encoding/json" "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strconv" @@ -16,6 +18,8 @@ const ( githubAPIURL = "https://api.github.com/repos/zsoftly/ztiaws/releases/latest" cacheExpiration = 24 * time.Hour installDocsURL = "https://github.com/zsoftly/ztiaws/blob/main/INSTALLATION.md" + maxRetries = 3 + retryDelay = 1 * time.Second ) type GitHubRelease struct { @@ -30,40 +34,111 @@ type VersionCache struct { // CheckLatestVersion checks if there's a newer version available func CheckLatestVersion(currentVersion string) (isOutdated bool, latestVersion string, err error) { + // Check if version check is explicitly disabled + if os.Getenv("ZTICTL_SKIP_VERSION_CHECK") == "true" { + return false, "", fmt.Errorf("version check disabled by ZTICTL_SKIP_VERSION_CHECK") + } + // Try to get from cache first cached, err := getFromCache() if err == nil && time.Since(cached.CheckedAt) < cacheExpiration { return compareVersions(currentVersion, cached.LatestVersion), cached.LatestVersion, nil } - // Fetch from GitHub API - client := &http.Client{Timeout: 3 * time.Second} - resp, err := client.Get(githubAPIURL) - if err != nil { - return false, "", err - } - defer resp.Body.Close() + // Fetch from GitHub API with retry logic + client := createHTTPClient() - if resp.StatusCode != http.StatusOK { - return false, "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) - } + var lastErr error + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(retryDelay * time.Duration(attempt)) + } - body, err := io.ReadAll(resp.Body) - if err != nil { - return false, "", err + req, err := http.NewRequest("GET", githubAPIURL, nil) + if err != nil { + lastErr = err + continue + } + + // Add GitHub token authentication if available (increases rate limit from 60 to 5000/hour) + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + req.Header.Set("Authorization", "token "+token) + } + + // Set User-Agent to identify the client + req.Header.Set("User-Agent", "ztictl-version-checker") + + resp, err := client.Do(req) + if err != nil { + lastErr = fmt.Errorf("network request failed (attempt %d/%d): %w", attempt+1, maxRetries, err) + continue + } + + // Handle response + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) + // Don't retry on client errors (4xx) + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + break + } + continue + } + + var release GitHubRelease + if err := json.Unmarshal(body, &release); err != nil { + lastErr = fmt.Errorf("failed to parse response: %w", err) + continue + } + + latestVersion = strings.TrimPrefix(release.TagName, "v") + + // Cache the result + _ = saveToCache(latestVersion) + + return compareVersions(currentVersion, latestVersion), latestVersion, nil } - var release GitHubRelease - if err := json.Unmarshal(body, &release); err != nil { - return false, "", err + return false, "", lastErr +} + +// createHTTPClient creates an HTTP client configured for production environments +func createHTTPClient() *http.Client { + // Create transport with proxy support + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, // Respects HTTP_PROXY, HTTPS_PROXY, NO_PROXY + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, // Use TLS 1.2 or higher + }, } - latestVersion = strings.TrimPrefix(release.TagName, "v") + // SECURITY WARNING: This option disables TLS certificate verification and makes + // connections vulnerable to man-in-the-middle (MITM) attacks. Only use this in + // trusted corporate environments with custom certificate authorities where you + // cannot install the CA certificates. This should NEVER be used in production + // without understanding the security implications. + if os.Getenv("ZTICTL_INSECURE_SKIP_VERIFY") == "true" { + transport.TLSClientConfig.InsecureSkipVerify = true + } - // Cache the result - _ = saveToCache(latestVersion) + // Support custom proxy configuration + if proxyURL := os.Getenv("ZTICTL_HTTPS_PROXY"); proxyURL != "" { + if proxy, err := url.Parse(proxyURL); err == nil { + transport.Proxy = http.ProxyURL(proxy) + } + } - return compareVersions(currentVersion, latestVersion), latestVersion, nil + return &http.Client{ + Timeout: 10 * time.Second, // Increased timeout for production networks + Transport: transport, + } } // compareVersions returns true if current is older than latest @@ -178,12 +253,32 @@ func PrintVersionWithCheck(currentVersion string) { isOutdated, latestVersion, err := CheckLatestVersion(currentVersion) if err != nil { - // Silently ignore errors - don't interrupt version output + // Show error in debug mode or if explicitly requested + if os.Getenv("ZTICTL_DEBUG") == "true" || os.Getenv("ZTICTL_VERBOSE_VERSION") == "true" { + fmt.Fprintf(os.Stderr, "\n[WARN] Version check failed: %v\n", err) + fmt.Fprintf(os.Stderr, " This is normal in restricted network environments.\n") + fmt.Fprintf(os.Stderr, " To disable this check, set: ZTICTL_SKIP_VERSION_CHECK=true\n") + + // Provide helpful hints for common issues + if strings.Contains(err.Error(), "network request failed") { + fmt.Fprintf(os.Stderr, "\n[INFO] Troubleshooting tips:\n") + fmt.Fprintf(os.Stderr, " - Check network connectivity to api.github.com\n") + fmt.Fprintf(os.Stderr, " - Configure proxy: export HTTPS_PROXY=http://proxy:port\n") + fmt.Fprintf(os.Stderr, " - Or use: export ZTICTL_HTTPS_PROXY=http://proxy:port\n") + fmt.Fprintf(os.Stderr, " - For corporate proxies with custom certs: export ZTICTL_INSECURE_SKIP_VERIFY=true\n") + fmt.Fprintf(os.Stderr, " - Authenticate to GitHub: export GITHUB_TOKEN=your_token\n") + } + } return } if isOutdated { - fmt.Printf("\nYour version of ztictl is out of date! The latest version\n") - fmt.Printf("is %s. You can update by downloading from %s\n", latestVersion, installDocsURL) + fmt.Printf("\n[WARN] Update Available: %s -> %s\n", currentVersion, latestVersion) + fmt.Printf("[INFO] Download: %s\n", installDocsURL) + } else { + // Only show "up to date" message in verbose mode + if os.Getenv("ZTICTL_VERBOSE_VERSION") == "true" { + fmt.Printf("\n[OK] You are using the latest version!\n") + } } } From db014f24d278a84f101b72880de2be67bfd8a5cd Mon Sep 17 00:00:00 2001 From: ditahkk Date: Fri, 7 Nov 2025 09:05:39 -0500 Subject: [PATCH 3/6] fix: Increase HTTP client timeout for EC2 instance detection and add warning for proxy parsing errors --- ztictl/internal/auth/sso.go | 4 ++-- ztictl/pkg/version/checker.go | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ztictl/internal/auth/sso.go b/ztictl/internal/auth/sso.go index 862482a..7c7829f 100644 --- a/ztictl/internal/auth/sso.go +++ b/ztictl/internal/auth/sso.go @@ -148,9 +148,9 @@ func DetectEnvironmentCredentials() (bool, CredentialType) { // isEC2Instance checks if running on an EC2 instance by querying IMDS // Uses a short timeout to avoid blocking if not on EC2 func isEC2Instance() bool { - // Create HTTP client with very short timeout + // Create HTTP client with short timeout (AWS recommends 1 second minimum) client := &http.Client{ - Timeout: 100 * time.Millisecond, + Timeout: 1000 * time.Millisecond, } // Try to reach EC2 instance metadata service (IMDSv2 token endpoint) diff --git a/ztictl/pkg/version/checker.go b/ztictl/pkg/version/checker.go index a1403ef..01a1527 100644 --- a/ztictl/pkg/version/checker.go +++ b/ztictl/pkg/version/checker.go @@ -132,6 +132,8 @@ func createHTTPClient() *http.Client { if proxyURL := os.Getenv("ZTICTL_HTTPS_PROXY"); proxyURL != "" { if proxy, err := url.Parse(proxyURL); err == nil { transport.Proxy = http.ProxyURL(proxy) + } else { + fmt.Fprintf(os.Stderr, "[WARN] Failed to parse ZTICTL_HTTPS_PROXY '%s': %v\n", proxyURL, err) } } From a4033fc0cf9c4decd1d8785e07f1263268e89ca9 Mon Sep 17 00:00:00 2001 From: ditahkk Date: Fri, 7 Nov 2025 09:23:20 -0500 Subject: [PATCH 4/6] Enhance AWS SSO and CI/CD Integration --- .github/workflows/build.yml | 10 +++++----- install.sh => 01_install.sh | 0 uninstall.sh => 02_uninstall.sh | 0 INSTALLATION.md | 4 ++-- docs/CI_CD_PIPELINE.md | 10 +++++----- .../PRODUCTION_VERSION_FIX.md | 0 QUICK_START.md => docs/QUICK_START.md | 6 +++--- .../archive/go_best_practices.txt | 0 go_migration.txt => docs/archive/go_migration.txt | 0 .../development/BUILD_ARTIFACTS.md | 0 RELEASE.md => docs/development/RELEASE.md | 0 build-windows-dev.sh => scripts/build-windows-dev.sh | 0 .../powershell-profile.ps1 | 0 ztictl/README.md | 2 +- 14 files changed, 16 insertions(+), 16 deletions(-) rename install.sh => 01_install.sh (100%) rename uninstall.sh => 02_uninstall.sh (100%) rename PRODUCTION_VERSION_FIX.md => docs/PRODUCTION_VERSION_FIX.md (100%) rename QUICK_START.md => docs/QUICK_START.md (93%) rename go_best_practices.txt => docs/archive/go_best_practices.txt (100%) rename go_migration.txt => docs/archive/go_migration.txt (100%) rename BUILD_ARTIFACTS.md => docs/development/BUILD_ARTIFACTS.md (100%) rename RELEASE.md => docs/development/RELEASE.md (100%) rename build-windows-dev.sh => scripts/build-windows-dev.sh (100%) rename Microsoft.PowerShell_profile.ps1 => scripts/powershell-profile.ps1 (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index df8432c..562d6cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,8 +20,8 @@ on: - 'src/**' - 'tests/**' - 'tools/**' - - 'install.sh' - - 'uninstall.sh' + - '01_install.sh' + - '02_uninstall.sh' - '.github/workflows/build.yml' - 'go.mod' - 'go.sum' @@ -36,8 +36,8 @@ on: - 'src/**' - 'tests/**' - 'tools/**' - - 'install.sh' - - 'uninstall.sh' + - '01_install.sh' + - '02_uninstall.sh' - '.github/workflows/build.yml' - 'go.mod' - 'go.sum' @@ -70,7 +70,7 @@ jobs: - name: Run shellcheck on shell scripts run: | echo "Checking shell scripts..." - shellcheck -x authaws ssm src/*.sh tools/*.sh install.sh uninstall.sh + shellcheck -x authaws ssm src/*.sh tools/*.sh scripts/*.sh 01_install.sh 02_uninstall.sh echo "Shell scripts passed validation" - name: Test shell script syntax diff --git a/install.sh b/01_install.sh similarity index 100% rename from install.sh rename to 01_install.sh diff --git a/uninstall.sh b/02_uninstall.sh similarity index 100% rename from uninstall.sh rename to 02_uninstall.sh diff --git a/INSTALLATION.md b/INSTALLATION.md index 3cec574..b24fe60 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -300,7 +300,7 @@ git clone https://github.com/zsoftly/ztiaws.git cd ztiaws # Step 2: Install (no build tools required) -./install.sh +./01_install.sh # Step 3: Verify authaws --check @@ -313,7 +313,7 @@ The installation script automatically: - ✅ Sets up proper permissions - ✅ Verifies installation works correctly -**To uninstall:** `./uninstall.sh` +**To uninstall:** `./02_uninstall.sh` ### **For Developers (Development Setup):** diff --git a/docs/CI_CD_PIPELINE.md b/docs/CI_CD_PIPELINE.md index ddcfd2e..5b0a0bd 100644 --- a/docs/CI_CD_PIPELINE.md +++ b/docs/CI_CD_PIPELINE.md @@ -34,9 +34,9 @@ on: push: branches: [ main, 'feature/*', 'feat/*', 'issue/*', 'release/*' ] tags: [ 'v*' ] - paths: [ - 'ztictl/**', 'authaws', 'ssm', 'src/**', 'tools/**', - 'install.sh', 'uninstall.sh', 'go.mod', 'go.sum', 'Makefile' + paths: [ + 'ztictl/**', 'authaws', 'ssm', 'src/**', 'tools/**', + '01_install.sh', '02_uninstall.sh', 'go.mod', 'go.sum', 'Makefile' ] pull_request: branches: [ main, 'release/*' ] @@ -309,7 +309,7 @@ needs: [release] ## Release Process Integration ### **Git Flow with Automation** -The CI/CD pipeline integrates with the release process defined in [RELEASE.md](../RELEASE.md): +The CI/CD pipeline integrates with the release process defined in [RELEASE.md](development/RELEASE.md): 1. **Create release branch**: `git checkout -b release/v2.7.0` 2. **Push triggers docs generation**: Auto-generates CHANGELOG.md via workflow @@ -385,7 +385,7 @@ The CI/CD pipeline integrates with the release process defined in [RELEASE.md](. * Main pipeline: `.github/workflows/build.yml` * Documentation: `.github/workflows/auto-generate-docs.yml` -* Release process: `RELEASE.md` +* Release process: `docs/development/RELEASE.md` * Build config: `ztictl/Makefile` * Dependencies: `ztictl/go.mod`, `ztictl/go.sum` * Notification scripts: `scripts/send-*.sh` diff --git a/PRODUCTION_VERSION_FIX.md b/docs/PRODUCTION_VERSION_FIX.md similarity index 100% rename from PRODUCTION_VERSION_FIX.md rename to docs/PRODUCTION_VERSION_FIX.md diff --git a/QUICK_START.md b/docs/QUICK_START.md similarity index 93% rename from QUICK_START.md rename to docs/QUICK_START.md index 1575ec0..4ac0f90 100644 --- a/QUICK_START.md +++ b/docs/QUICK_START.md @@ -85,6 +85,6 @@ curl -I https://github.com/zsoftly/ztiaws/releases/latest/download/ztictl-linux- ``` ## 📚 Full Documentation -- **Installation Guide**: [INSTALLATION.md](INSTALLATION.md) -- **Release Process**: [RELEASE.md](RELEASE.md) -- **User Guide**: [README.md](README.md) +- **Installation Guide**: [INSTALLATION.md](../INSTALLATION.md) +- **Release Process**: [RELEASE.md](development/RELEASE.md) +- **User Guide**: [README.md](../README.md) diff --git a/go_best_practices.txt b/docs/archive/go_best_practices.txt similarity index 100% rename from go_best_practices.txt rename to docs/archive/go_best_practices.txt diff --git a/go_migration.txt b/docs/archive/go_migration.txt similarity index 100% rename from go_migration.txt rename to docs/archive/go_migration.txt diff --git a/BUILD_ARTIFACTS.md b/docs/development/BUILD_ARTIFACTS.md similarity index 100% rename from BUILD_ARTIFACTS.md rename to docs/development/BUILD_ARTIFACTS.md diff --git a/RELEASE.md b/docs/development/RELEASE.md similarity index 100% rename from RELEASE.md rename to docs/development/RELEASE.md diff --git a/build-windows-dev.sh b/scripts/build-windows-dev.sh similarity index 100% rename from build-windows-dev.sh rename to scripts/build-windows-dev.sh diff --git a/Microsoft.PowerShell_profile.ps1 b/scripts/powershell-profile.ps1 similarity index 100% rename from Microsoft.PowerShell_profile.ps1 rename to scripts/powershell-profile.ps1 diff --git a/ztictl/README.md b/ztictl/README.md index 8abc8d9..8d150aa 100644 --- a/ztictl/README.md +++ b/ztictl/README.md @@ -311,7 +311,7 @@ git push origin v1.1.0 # ✅ Uploads cross-platform binaries ``` -> **📋 Details:** See [RELEASE.md](../RELEASE.md) for complete release procedures. +> **📋 Details:** See [RELEASE.md](../docs/development/RELEASE.md) for complete release procedures. ## Contributing From ed0952892dc2a88df925f1d0ef43b8459072ddb1 Mon Sep 17 00:00:00 2001 From: ditahkk Date: Fri, 7 Nov 2025 09:49:15 -0500 Subject: [PATCH 5/6] fix: Update script paths and improve error handling in notification scripts --- scripts/build-windows-dev.sh | 18 ++++++++--------- scripts/send-pr-notification.sh | 30 +++++++++++++++++----------- scripts/send-release-notification.sh | 18 +++++++++++------ 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/scripts/build-windows-dev.sh b/scripts/build-windows-dev.sh index 67d0989..05ef2fc 100755 --- a/scripts/build-windows-dev.sh +++ b/scripts/build-windows-dev.sh @@ -3,18 +3,18 @@ # Variables SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$SCRIPT_DIR/ztictl" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")/ztictl" WINDOWS_TOOLS_DIR="/mnt/c/Tools" BUILD_NAME="ztictl.exe" # Source logging utilities -source "$SCRIPT_DIR/src/00_utils.sh" +source "$(dirname "$SCRIPT_DIR")/src/00_utils.sh" # Initialize logging for this script init_logging "build-windows-dev.sh" # Get dynamic version from git -cd "$PROJECT_DIR" +cd "$PROJECT_DIR" || exit GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "dev") VERSION="3.0.$GIT_COMMIT" @@ -24,19 +24,17 @@ log_info "Target directory: $WINDOWS_TOOLS_DIR" log_info "Version: $VERSION" echo "" -cd "$PROJECT_DIR" +cd "$PROJECT_DIR" || exit # Build with flags that might reduce antivirus detection log_info "Building with antivirus-friendly flags..." -GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build \ +if GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build \ -a \ -installsuffix cgo \ -ldflags "-X main.version=$VERSION -s -w -buildid=" \ -trimpath \ -o "builds/$BUILD_NAME" \ - ./cmd/ztictl - -if [ $? -eq 0 ]; then + ./cmd/ztictl; then log_info "Build successful" # Copy to Windows Tools directory @@ -48,15 +46,15 @@ if [ $? -eq 0 ]; then echo "" log_warn "To avoid antivirus issues:" echo "1. Add C:\\Tools to Windows Defender exclusions" - echo "2. Or run: Unblock-File -Path 'C:\\Tools\\$BUILD_NAME'" + printf "2. Or run: Unblock-File -Path 'C:\\\\Tools\\\\%s'\n" "$BUILD_NAME" echo "" log_info "Ready to test on Windows!" log_info "Run: cd C:\\Tools && .\\$BUILD_NAME --help" else log_error "Build failed" - log_completion exit 1 fi # Log completion +# shellcheck disable=SC2119 log_completion \ No newline at end of file diff --git a/scripts/send-pr-notification.sh b/scripts/send-pr-notification.sh index c215dbd..d9f8928 100755 --- a/scripts/send-pr-notification.sh +++ b/scripts/send-pr-notification.sh @@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || { } if [ -f "${SCRIPT_DIR}/../src/00_utils.sh" ]; then - # shellcheck source=../src/00_utils.sh + # shellcheck source=../src/00_utils.sh disable=SC1091 source "${SCRIPT_DIR}/../src/00_utils.sh" || { echo "Error: Failed to source utilities file" >&2 exit 1 @@ -105,6 +105,7 @@ parse_arguments() { fi # Validate required parameters using centralized function + # shellcheck disable=SC2317 validate_notification_params "$WEBHOOK_URL" "$PR_TITLE" "$PR_NUMBER" "$PR_URL" "$AUTHOR" "$REPOSITORY" || { usage; exit 1; } } @@ -112,28 +113,33 @@ parse_arguments() { create_chat_payload() { log_debug "Creating Google Chat App Card payload" - local escaped_title=$(escape_json "$PR_TITLE") - local escaped_author=$(escape_json "$AUTHOR") - local escaped_repository=$(escape_json "$REPOSITORY") - local escaped_pr_number=$(escape_json "$PR_NUMBER") + local escaped_title + local escaped_author + local escaped_repository + local escaped_pr_number + local escaped_pr_url + local escaped_files_url + local escaped_message + + escaped_title=$(escape_json "$PR_TITLE") + escaped_author=$(escape_json "$AUTHOR") + escaped_repository=$(escape_json "$REPOSITORY") + escaped_pr_number=$(escape_json "$PR_NUMBER") local files_url="${PR_URL}/files" - local escaped_pr_url=$(escape_json "$PR_URL") - local escaped_files_url=$(escape_json "$files_url") - local escaped_message=$(escape_json "${MESSAGE:-🔄 New pull request opened}") + escaped_pr_url=$(escape_json "$PR_URL") + escaped_files_url=$(escape_json "$files_url") + escaped_message=$(escape_json "${MESSAGE:-🔄 New pull request opened}") # Determine status icon and header local status_icon="NOTIFICATION_ICON" local header_title="🔄 New Pull Request" - local status_color="" - + if [[ "$STATUS" == "success" ]]; then status_icon="STAR" header_title="✅ PR Ready for Review" - status_color="" elif [[ "$STATUS" == "failure" ]]; then status_icon="ERROR" header_title="❌ PR Tests Failed" - status_color="" fi cat << EOF diff --git a/scripts/send-release-notification.sh b/scripts/send-release-notification.sh index a8d8883..17dbfe2 100755 --- a/scripts/send-release-notification.sh +++ b/scripts/send-release-notification.sh @@ -18,7 +18,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || { } if [ -f "${SCRIPT_DIR}/../src/00_utils.sh" ]; then - # shellcheck source=../src/00_utils.sh + # shellcheck source=../src/00_utils.sh disable=SC1091 source "${SCRIPT_DIR}/../src/00_utils.sh" || { echo "Error: Failed to source utilities file" >&2 exit 1 @@ -83,18 +83,24 @@ parse_arguments() { fi # Validate required parameters using centralized function + # shellcheck disable=SC2317 validate_release_params "$WEBHOOK_URL" "$VERSION" "$RELEASE_URL" "$REPOSITORY" || { usage; exit 1; } } # --- Create Google Chat App Card JSON --- create_chat_payload() { log_debug "Creating Google Chat App Card payload" - - local escaped_version=$(escape_json "$VERSION") - local escaped_repository=$(escape_json "$REPOSITORY") - local escaped_release_url=$(escape_json "$RELEASE_URL") + + local escaped_version + local escaped_repository + local escaped_release_url + local escaped_changelog_url + + escaped_version=$(escape_json "$VERSION") + escaped_repository=$(escape_json "$REPOSITORY") + escaped_release_url=$(escape_json "$RELEASE_URL") local changelog_url="https://github.com/$REPOSITORY/blob/main/CHANGELOG.md" - local escaped_changelog_url=$(escape_json "$changelog_url") + escaped_changelog_url=$(escape_json "$changelog_url") cat << EOF { From 07cc37dfaa077309da2d830cfa65b12fb5415d19 Mon Sep 17 00:00:00 2001 From: ditahkk Date: Wed, 12 Nov 2025 17:05:10 -0500 Subject: [PATCH 6/6] fix: Update build scripts and enhance error handling in OIDC examples --- .github/workflows/build.yml | 2 +- docs/examples/github-actions-oidc.yml | 10 +++++----- docs/examples/gitlab-ci-oidc.yml | 16 +++++++++------- ztictl/cmd/ztictl/ssm_power_test.go | 25 ++++++++++++++++--------- 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 562d6cc..a398839 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,7 +76,7 @@ jobs: - name: Test shell script syntax run: | echo "Testing shell script syntax..." - for script in authaws ssm src/*.sh tools/*.sh; do + for script in authaws ssm src/*.sh tools/*.sh scripts/*.sh 01_install.sh 02_uninstall.sh; do if [ -f "$script" ]; then bash -n "$script" || exit 1 fi diff --git a/docs/examples/github-actions-oidc.yml b/docs/examples/github-actions-oidc.yml index f9be86d..2f50081 100644 --- a/docs/examples/github-actions-oidc.yml +++ b/docs/examples/github-actions-oidc.yml @@ -56,7 +56,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Configure AWS credentials using OIDC - name: Configure AWS Credentials @@ -148,7 +148,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v2 @@ -209,7 +209,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v2 @@ -254,7 +254,7 @@ jobs: # Upload logs as GitHub artifacts - name: Upload logs as artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: application-logs @@ -276,7 +276,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v2 diff --git a/docs/examples/gitlab-ci-oidc.yml b/docs/examples/gitlab-ci-oidc.yml index 14ee2f8..2bee259 100644 --- a/docs/examples/gitlab-ci-oidc.yml +++ b/docs/examples/gitlab-ci-oidc.yml @@ -57,8 +57,8 @@ stages: echo $GITLAB_OIDC_TOKEN > $AWS_WEB_IDENTITY_TOKEN_FILE export AWS_ROLE_ARN=$AWS_ROLE_ARN export AWS_DEFAULT_REGION=$AWS_REGION - # Install AWS CLI - - apk add --no-cache aws-cli curl bash + # Install AWS CLI and glibc compatibility layer + - apk add --no-cache aws-cli curl bash libc6-compat # Verify AWS credentials - aws sts get-caller-identity # Install ztictl @@ -67,12 +67,14 @@ stages: chmod +x ztictl mv ztictl /usr/local/bin/ ztictl --version - # Install AWS Session Manager plugin + # Install AWS Session Manager plugin (tarball for Alpine compatibility) - | - curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" \ - -o "session-manager-plugin.deb" - apk add --no-cache dpkg - dpkg -i session-manager-plugin.deb || true + curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/linux_64bit/session-manager-plugin.tar.gz" \ + -o "session-manager-plugin.tar.gz" + tar -xzf session-manager-plugin.tar.gz + mv sessionmanager-bundle/bin/session-manager-plugin /usr/local/bin/ + chmod +x /usr/local/bin/session-manager-plugin + session-manager-plugin --version # Initialize ztictl configuration - ztictl config init --non-interactive diff --git a/ztictl/cmd/ztictl/ssm_power_test.go b/ztictl/cmd/ztictl/ssm_power_test.go index 5ee3fa4..d34bfb2 100644 --- a/ztictl/cmd/ztictl/ssm_power_test.go +++ b/ztictl/cmd/ztictl/ssm_power_test.go @@ -863,38 +863,45 @@ func TestStateValidationRequirements(t *testing.T) { operation string expectedStates []string requireSSMOnline bool + expectError bool }{ { name: "start operation requires stopped state", operation: "start", expectedStates: []string{"stopped"}, requireSSMOnline: false, + expectError: false, }, { name: "stop operation requires running state", operation: "stop", expectedStates: []string{"running"}, requireSSMOnline: false, + expectError: false, }, { name: "reboot operation requires running state", operation: "reboot", expectedStates: []string{"running"}, requireSSMOnline: false, + expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var requirements InstanceValidationRequirements - requirements.RequireSSMOnline = false - requirements.Operation = tt.operation - - switch tt.operation { - case "start": - requirements.AllowedStates = []string{"stopped"} - case "stop", "reboot": - requirements.AllowedStates = []string{"running"} + requirements, err := buildRequirementsForOperation(tt.operation) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("Expected no error but got: %v", err) + return } if requirements.Operation != tt.operation {