Skip to content

Commit e22e370

Browse files
authored
feat(bootstrapper): add idempotent operations for secrets and organizations (#338)
## 📝 Description This change ensures idempotency when bootstrapping installations, allowing gen-secrets and init-org to be run multiple times safely. Root Users credentials are now keps in authentication secret, because this one is not managed by the helm chart and is preserved between cluster restarts. - Use CreateSecretIfNotExists instead of UpsertSecret for all secret generation - Ensure secrets aren't overwritten when they already exist - Add Describe method to OrgClient to check if an organization exists - Add OrganizationExists function to verify existing organizations - Modify CreateSemaphoreOrganization to use existing orgs when available - Update init-org command to skip creation if organization already exists - Add corresponding unit tests for both secret and organization functionality New way of extracting Root User credentials ```bash echo "Email: $(kubectl get secret semaphore-authentication -n default -o jsonpath='{.data.ROOT_USER_EMAIL}' | base64 -d)"; echo "Password: $(kubectl get secret semaphore-authentication -n default -o jsonpath='{.data.ROOT_USER_PASSWORD}' | base64 -d)"; echo "API Token: $(kubectl get secret semaphore-authentication -n default -o jsonpath='{.data.ROOT_USER_TOKEN}' | base64 -d)" ``` ## ✅ Checklist - [x] I have tested this change - [x] This change requires documentation update
1 parent c105bd7 commit e22e370

File tree

13 files changed

+1058
-34
lines changed

13 files changed

+1058
-34
lines changed

bootstrapper/cmd/gen_secrets.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ var genSecretsCmd = &cobra.Command{
3333

3434
func generateJWTSecret(client *kubernetes.KubernetesClient) {
3535
secretName := utils.AssertEnv("JWT_SECRET_NAME")
36-
err := client.UpsertSecret(secretName, map[string]string{
36+
err := client.CreateSecretIfNotExists(secretName, map[string]string{
3737
"logs": random.Base64String(32),
3838
"artifacts": random.Base64String(32),
3939
})
@@ -45,7 +45,7 @@ func generateJWTSecret(client *kubernetes.KubernetesClient) {
4545

4646
func generateAuthenticationSecret(client *kubernetes.KubernetesClient) {
4747
secretName := utils.AssertEnv("AUTHENTICATION_SECRET_NAME")
48-
err := client.UpsertSecret(secretName, map[string]string{
48+
err := client.CreateSecretIfNotExists(secretName, map[string]string{
4949
"SESSION_SECRET_KEY_BASE": random.Base64String(64),
5050
"TOKEN_HASHING_SALT": random.Base64String(32),
5151
"OIDC_CLIENT_SECRET": random.Base64String(32),
@@ -55,13 +55,13 @@ func generateAuthenticationSecret(client *kubernetes.KubernetesClient) {
5555
})
5656

5757
if err != nil {
58-
log.Fatalf("Failed to generate JWT secrets: %v", err)
58+
log.Fatalf("Failed to generate authentication secrets: %v", err)
5959
}
6060
}
6161

6262
func generateEncryptionKey(client *kubernetes.KubernetesClient) {
6363
secretName := utils.AssertEnv("ENCRYPTION_SECRET_NAME")
64-
err := client.UpsertSecret(secretName, map[string]string{
64+
err := client.CreateSecretIfNotExists(secretName, map[string]string{
6565
"key": random.Base64String(32),
6666
})
6767

@@ -102,7 +102,7 @@ func generateOpenIDSecret(client *kubernetes.KubernetesClient) error {
102102
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
103103
privateKeyName := fmt.Sprintf("%s.pem", timestamp)
104104

105-
err = client.UpsertSecret(secretName, map[string]string{
105+
err = client.CreateSecretIfNotExists(secretName, map[string]string{
106106
privateKeyName: string(privateKey),
107107
})
108108

@@ -124,7 +124,7 @@ func generateVaultSecret(client *kubernetes.KubernetesClient) error {
124124
privateKeyName := fmt.Sprintf("%s.prv.pem", timestamp)
125125
publicKeyName := fmt.Sprintf("%s.pub.pem", timestamp)
126126

127-
err = client.UpsertSecret(secretName, map[string]string{
127+
err = client.CreateSecretIfNotExists(secretName, map[string]string{
128128
privateKeyName: string(privateKey),
129129
publicKeyName: string(publicKey),
130130
})

bootstrapper/cmd/gen_secrets_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,14 @@ func TestGenerateOpenIDSecret(t *testing.T) {
9999

100100
// Verify there's exactly one key pair with correct format
101101
foundPrivate := false
102+
originalKeyName := ""
102103

103104
// Helper function to check keys in either StringData or Data
104105
checkKeys := func(data map[string]string) {
105106
for k, v := range data {
106107
if strings.HasSuffix(k, ".pem") && strings.Contains(v, "-----BEGIN RSA PRIVATE KEY-----") {
107108
foundPrivate = true
109+
originalKeyName = k
108110
}
109111
}
110112
}
@@ -122,6 +124,30 @@ func TestGenerateOpenIDSecret(t *testing.T) {
122124
}
123125

124126
assert.True(t, foundPrivate, "No valid RSA private key found in secret")
127+
128+
// Now test that calling it again doesn't change the secret
129+
err = generateOpenIDSecret(mockClient.KubernetesClient)
130+
assert.NoError(t, err)
131+
132+
// Get the secret again
133+
updatedSecret, err := mockClient.Clientset.CoreV1().Secrets("test").Get(context.Background(), "openid-secret", metav1.GetOptions{})
134+
assert.NoError(t, err)
135+
136+
// Verify the original key is still there and no new key was added
137+
found := false
138+
keyCount := 0
139+
140+
// Check in StringData or Data
141+
if len(updatedSecret.StringData) > 0 {
142+
keyCount = len(updatedSecret.StringData)
143+
_, found = updatedSecret.StringData[originalKeyName]
144+
} else if len(updatedSecret.Data) > 0 {
145+
keyCount = len(updatedSecret.Data)
146+
_, found = updatedSecret.Data[originalKeyName]
147+
}
148+
149+
assert.True(t, found, "Original key should still be present")
150+
assert.Equal(t, 1, keyCount, "There should still be only one key")
125151
}
126152

127153
func TestGenerateVaultSecret(t *testing.T) {
@@ -145,6 +171,8 @@ func TestGenerateVaultSecret(t *testing.T) {
145171
foundPrivate := false
146172
foundPublic := false
147173
timestamp := ""
174+
originalPrivateKey := ""
175+
originalPublicKey := ""
148176

149177
// Helper function to check keys in either StringData or Data
150178
checkKeys := func(data map[string]string) {
@@ -153,9 +181,11 @@ func TestGenerateVaultSecret(t *testing.T) {
153181
foundPrivate = true
154182
// Extract timestamp from the key name
155183
timestamp = strings.TrimSuffix(k, ".prv.pem")
184+
originalPrivateKey = k
156185
}
157186
if strings.HasSuffix(k, ".pub.pem") && strings.Contains(v, "-----BEGIN RSA PUBLIC KEY-----") {
158187
foundPublic = true
188+
originalPublicKey = k
159189
}
160190
}
161191
}
@@ -179,6 +209,92 @@ func TestGenerateVaultSecret(t *testing.T) {
179209
if foundPrivate && foundPublic {
180210
assert.Contains(t, secret.StringData, fmt.Sprintf("%s.pub.pem", timestamp), "Public key name does not match private key timestamp")
181211
}
212+
213+
// Test CreateSecretIfNotExists behavior by calling generateVaultSecret again
214+
err = generateVaultSecret(mockClient.KubernetesClient)
215+
assert.NoError(t, err)
216+
217+
// Get the secret again
218+
updatedSecret, err := mockClient.Clientset.CoreV1().Secrets("test").Get(context.Background(), utils.AssertEnv("VAULT_SECRET_NAME"), metav1.GetOptions{})
219+
assert.NoError(t, err)
220+
221+
// Verify the original keys are still there and no new keys were added
222+
foundOrigPrivate := false
223+
foundOrigPublic := false
224+
keyCount := 0
225+
226+
// Check in StringData or Data
227+
if len(updatedSecret.StringData) > 0 {
228+
keyCount = len(updatedSecret.StringData)
229+
_, foundOrigPrivate = updatedSecret.StringData[originalPrivateKey]
230+
_, foundOrigPublic = updatedSecret.StringData[originalPublicKey]
231+
} else if len(updatedSecret.Data) > 0 {
232+
keyCount = len(updatedSecret.Data)
233+
_, foundOrigPrivate = updatedSecret.Data[originalPrivateKey]
234+
_, foundOrigPublic = updatedSecret.Data[originalPublicKey]
235+
}
236+
237+
assert.True(t, foundOrigPrivate, "Original private key should still be present")
238+
assert.True(t, foundOrigPublic, "Original public key should still be present")
239+
assert.Equal(t, 2, keyCount, "There should still be exactly two keys")
240+
}
241+
242+
func TestBasicSecretsGeneration(t *testing.T) {
243+
// Set required env vars
244+
cleanup := setTestEnv("")
245+
defer cleanup()
246+
247+
mockClient := NewMockKubernetesClient()
248+
249+
// First generation of secrets
250+
generateJWTSecret(mockClient.KubernetesClient)
251+
generateAuthenticationSecret(mockClient.KubernetesClient)
252+
generateEncryptionKey(mockClient.KubernetesClient)
253+
254+
// Verify the secrets were created
255+
secretNames := []string{
256+
utils.AssertEnv("JWT_SECRET_NAME"),
257+
utils.AssertEnv("AUTHENTICATION_SECRET_NAME"),
258+
utils.AssertEnv("ENCRYPTION_SECRET_NAME"),
259+
}
260+
261+
for _, secretName := range secretNames {
262+
secret, err := mockClient.Clientset.CoreV1().Secrets("test").Get(context.Background(), secretName, metav1.GetOptions{})
263+
assert.NoError(t, err)
264+
assert.NotNil(t, secret)
265+
266+
// Store original data for comparison
267+
originalData := make(map[string]string)
268+
if len(secret.StringData) > 0 {
269+
for k, v := range secret.StringData {
270+
originalData[k] = v
271+
}
272+
} else if len(secret.Data) > 0 {
273+
for k, v := range secret.Data {
274+
originalData[k] = string(v)
275+
}
276+
}
277+
278+
// Call generation functions again
279+
generateJWTSecret(mockClient.KubernetesClient)
280+
generateAuthenticationSecret(mockClient.KubernetesClient)
281+
generateEncryptionKey(mockClient.KubernetesClient)
282+
283+
// Verify secrets haven't changed
284+
updatedSecret, err := mockClient.Clientset.CoreV1().Secrets("test").Get(context.Background(), secretName, metav1.GetOptions{})
285+
assert.NoError(t, err)
286+
287+
// Check that data is the same
288+
if len(updatedSecret.StringData) > 0 {
289+
for k, v := range updatedSecret.StringData {
290+
assert.Equal(t, originalData[k], v, "Secret data should not have changed for %s", secretName)
291+
}
292+
} else if len(updatedSecret.Data) > 0 {
293+
for k, v := range updatedSecret.Data {
294+
assert.Equal(t, originalData[k], string(v), "Secret data should not have changed for %s", secretName)
295+
}
296+
}
297+
}
182298
}
183299

184300
func TestSecretGenerationForEditions(t *testing.T) {

bootstrapper/cmd/init_org.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ var initOrgCmd = &cobra.Command{
3232
orgUsername := utils.AssertEnv("ORGANIZATION_USERNAME")
3333
userName := utils.AssertEnv("ROOT_NAME")
3434
userEmail := utils.AssertEnv("ROOT_EMAIL")
35-
rootUserSecretName := utils.AssertEnv("ROOT_USER_SECRET_NAME")
35+
authenticationSecretName := utils.AssertEnv("AUTHENTICATION_SECRET_NAME")
3636

3737
kubernetesClient := kubernetes.NewClient()
3838
instanceConfigClient := clients.NewInstanceConfigClient()
@@ -44,7 +44,15 @@ var initOrgCmd = &cobra.Command{
4444
//
4545
waitForIngress(domain)
4646

47-
userId := user.CreateSemaphoreUser(kubernetesClient, userName, userEmail, rootUserSecretName)
47+
// First check if the organization already exists
48+
exists, existingOrgId := organization.OrganizationExists(orgUsername)
49+
if exists {
50+
log.Infof("Organization %s already exists with ID %s. Skipping organization creation.", orgUsername, existingOrgId)
51+
// Return early since organization already exists
52+
return
53+
}
54+
55+
userId := user.CreateSemaphoreUser(kubernetesClient, userName, userEmail, authenticationSecretName)
4856
orgId := organization.CreateSemaphoreOrganization(orgUsername, userId)
4957

5058
if os.Getenv("DEFAULT_AGENT_TYPE_ENABLED") == "true" {

0 commit comments

Comments
 (0)