Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/api/tasks/syncing.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ def perform_github_actions_sync(environment_sync):
access_token, api_host = get_gh_actions_credentials(environment_sync)
repo_name = environment_sync.options.get("repo_name")
repo_owner = environment_sync.options.get("owner")
environment_name = environment_sync.options.get("environment_name")

handle_sync_event(
environment_sync,
Expand All @@ -265,6 +266,7 @@ def perform_github_actions_sync(environment_sync):
repo_name,
repo_owner,
api_host,
environment_name,
)


Expand Down
104 changes: 94 additions & 10 deletions backend/api/utils/syncing/github/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,50 @@ def get_gh_actions_credentials(environment_sync):
return access_token, api_host


def list_environments(credential_id, owner, repo_name):
"""Return a list of GitHub Actions environment names for a repo.

This requires at least the `repo` scope on the OAuth token.
"""
ProviderCredentials = apps.get_model("api", "ProviderCredentials")

pk, sk = get_server_keypair()
credential = ProviderCredentials.objects.get(id=credential_id)

access_token = decrypt_asymmetric(
credential.credentials["access_token"], sk.hex(), pk.hex()
)

api_host = GITHUB_CLOUD_API_URL
if "host" in credential.credentials:
api_host = decrypt_asymmetric(
credential.credentials["api_url"], sk.hex(), pk.hex()
)

api_host = normalize_api_host(api_host)

headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github+json"}

environments = []
page = 1
while True:
url = f"{api_host}/repos/{owner}/{repo_name}/environments?per_page=100&page={page}"
response = requests.get(url, headers=headers)
if response.status_code != 200:
raise Exception(
f"Error fetching environments: {response.status_code} {response.text}"
)

payload = response.json()
items = payload.get("environments", [])
if not items:
break
environments.extend([env.get("name") for env in items if env.get("name")])
page += 1

return environments


def encrypt_secret(public_key: str, secret_value: str) -> str:
pk = nacl.public.PublicKey(public_key, nacl.encoding.Base64Encoder())
box = nacl.public.SealedBox(pk)
Expand Down Expand Up @@ -136,8 +180,29 @@ def get_all_secrets(repo, owner, headers, api_host=GITHUB_CLOUD_API_URL):
return all_secrets


def get_all_env_secrets(repo, owner, environment_name, headers, api_host=GITHUB_CLOUD_API_URL):
api_host = normalize_api_host(api_host)
all_secrets = []
page = 1
while True:
response = requests.get(
f"{api_host}/repos/{owner}/{repo}/environments/{environment_name}/secrets?page={page}",
headers=headers,
)
if response.status_code != 200 or not response.json().get("secrets"):
break
all_secrets.extend(response.json()["secrets"])
page += 1
return all_secrets


def sync_github_secrets(
secrets, access_token, repo, owner, api_host=GITHUB_CLOUD_API_URL
secrets,
access_token,
repo,
owner,
api_host=GITHUB_CLOUD_API_URL,
environment_name=None,
):
api_host = normalize_api_host(api_host)

Expand All @@ -150,10 +215,14 @@ def sync_github_secrets(
"Accept": "application/vnd.github+json",
}

public_key_response = requests.get(
f"{api_host}/repos/{owner}/{repo}/actions/secrets/public-key",
headers=headers,
)
if environment_name:
public_key_url = (
f"{api_host}/repos/{owner}/{repo}/environments/{environment_name}/secrets/public-key"
)
else:
public_key_url = f"{api_host}/repos/{owner}/{repo}/actions/secrets/public-key"

public_key_response = requests.get(public_key_url, headers=headers)
if public_key_response.status_code != 200:
return False, {
"response_code": public_key_response.status_code,
Expand All @@ -165,7 +234,12 @@ def sync_github_secrets(
public_key_value = public_key["key"]

local_secrets = {k: v for k, v, _ in secrets}
existing_secrets = get_all_secrets(repo, owner, headers, api_host)
if environment_name:
existing_secrets = get_all_env_secrets(
repo, owner, environment_name, headers, api_host
)
else:
existing_secrets = get_all_secrets(repo, owner, headers, api_host)
existing_secret_names = {secret["name"] for secret in existing_secrets}

for key, value in local_secrets.items():
Expand All @@ -174,7 +248,12 @@ def sync_github_secrets(
continue # Skip oversized secret

secret_data = {"encrypted_value": encrypted_value, "key_id": key_id}
secret_url = f"{api_host}/repos/{owner}/{repo}/actions/secrets/{key}"
if environment_name:
secret_url = (
f"{api_host}/repos/{owner}/{repo}/environments/{environment_name}/secrets/{key}"
)
else:
secret_url = f"{api_host}/repos/{owner}/{repo}/actions/secrets/{key}"
response = requests.put(secret_url, headers=headers, json=secret_data)

if response.status_code not in [201, 204]:
Expand All @@ -185,9 +264,14 @@ def sync_github_secrets(

for secret_name in existing_secret_names:
if secret_name not in local_secrets:
delete_url = (
f"{api_host}/repos/{owner}/{repo}/actions/secrets/{secret_name}"
)
if environment_name:
delete_url = (
f"{api_host}/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}"
)
else:
delete_url = (
f"{api_host}/repos/{owner}/{repo}/actions/secrets/{secret_name}"
)
delete_response = requests.delete(delete_url, headers=headers)
if delete_response.status_code != 204:
return False, {
Expand Down
7 changes: 6 additions & 1 deletion backend/backend/graphene/mutations/syncing.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,14 @@ class Arguments:
credential_id = graphene.ID()
repo_name = graphene.String()
owner = graphene.String()
environment_name = graphene.String(required=False)

sync = graphene.Field(EnvironmentSyncType)

@classmethod
def mutate(cls, root, info, env_id, path, credential_id, repo_name, owner):
def mutate(
cls, root, info, env_id, path, credential_id, repo_name, owner, environment_name=None
):
service_id = "github_actions"
service_config = ServiceConfig.get_service_config(service_id)

Expand All @@ -283,6 +286,8 @@ def mutate(cls, root, info, env_id, path, credential_id, repo_name, owner):
raise GraphQLError("You don't have access to this app")

sync_options = {"repo_name": repo_name, "owner": owner}
if environment_name:
sync_options["environment_name"] = environment_name

existing_syncs = EnvironmentSync.objects.filter(
environment__app_id=env.app.id, service=service_id, deleted_at=None
Expand Down
10 changes: 9 additions & 1 deletion backend/backend/graphene/queries/syncing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
)
from api.services import Providers, ServiceConfig
from api.utils.syncing.aws.secrets_manager import list_aws_secrets
from api.utils.syncing.github.actions import list_repos
from api.utils.syncing.github.actions import list_repos, list_environments
from api.utils.syncing.vault.main import test_vault_creds
from api.utils.syncing.nomad.main import test_nomad_creds
from api.utils.syncing.gitlab.main import list_gitlab_groups, list_gitlab_projects
Expand Down Expand Up @@ -191,6 +191,14 @@ def resolve_gh_repos(root, info, credential_id):
raise GraphQLError(ex)


def resolve_github_environments(root, info, credential_id, owner, repo_name):
try:
envs = list_environments(credential_id, owner, repo_name)
return envs
except Exception as ex:
raise GraphQLError(ex)


def resolve_test_vault_creds(root, info, credential_id):
try:
valid = test_vault_creds(credential_id)
Expand Down
5 changes: 5 additions & 0 deletions backend/backend/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
from .graphene.queries.syncing import (
resolve_aws_secret_manager_secrets,
resolve_gh_repos,
resolve_github_environments,
resolve_gitlab_projects,
resolve_gitlab_groups,
resolve_server_public_key,
Expand Down Expand Up @@ -384,6 +385,9 @@ class Query(graphene.ObjectType):
GitHubRepoType,
credential_id=graphene.ID(),
)
github_environments = graphene.List(
graphene.String, credential_id=graphene.ID(), owner=graphene.String(), repo_name=graphene.String()
)

gitlab_projects = graphene.List(GitLabProjectType, credential_id=graphene.ID())
gitlab_groups = graphene.List(GitLabGroupType, credential_id=graphene.ID())
Expand Down Expand Up @@ -458,6 +462,7 @@ class Query(graphene.ObjectType):
resolve_aws_secrets = resolve_aws_secret_manager_secrets

resolve_github_repos = resolve_gh_repos
resolve_github_environments = resolve_github_environments

resolve_gitlab_projects = resolve_gitlab_projects
resolve_gitlab_groups = resolve_gitlab_groups
Expand Down
9 changes: 7 additions & 2 deletions frontend/apollo/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const documents = {
"mutation CreateNewCfWorkersSync($envId: ID!, $path: String!, $workerName: String!, $credentialId: ID!) {\n createCloudflareWorkersSync(\n envId: $envId\n path: $path\n workerName: $workerName\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": types.CreateNewCfWorkersSyncDocument,
"mutation DeleteProviderCreds($credentialId: ID!) {\n deleteProviderCredentials(credentialId: $credentialId) {\n ok\n }\n}": types.DeleteProviderCredsDocument,
"mutation DeleteSync($syncId: ID!) {\n deleteEnvSync(syncId: $syncId) {\n ok\n }\n}": types.DeleteSyncDocument,
"mutation CreateNewGhActionsSync($envId: ID!, $path: String!, $repoName: String!, $owner: String!, $credentialId: ID!) {\n createGhActionsSync(\n envId: $envId\n path: $path\n repoName: $repoName\n owner: $owner\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": types.CreateNewGhActionsSyncDocument,
"mutation CreateNewGhActionsSync($envId: ID!, $path: String!, $repoName: String!, $owner: String!, $credentialId: ID!, $environmentName: String) {\n createGhActionsSync(\n envId: $envId\n path: $path\n repoName: $repoName\n owner: $owner\n credentialId: $credentialId\n environmentName: $environmentName\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": types.CreateNewGhActionsSyncDocument,
"mutation CreateNewGitlabCiSync($envId: ID!, $path: String!, $credentialId: ID!, $resourcePath: String!, $resourceId: String!, $isGroup: Boolean!, $isMasked: Boolean!, $isProtected: Boolean!) {\n createGitlabCiSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n resourcePath: $resourcePath\n resourceId: $resourceId\n isGroup: $isGroup\n masked: $isMasked\n protected: $isProtected\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": types.CreateNewGitlabCiSyncDocument,
"mutation InitAppSyncing($appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n initEnvSync(appId: $appId, envKeys: $envKeys) {\n app {\n id\n sseEnabled\n }\n }\n}": types.InitAppSyncingDocument,
"mutation CreateNewNomadSync($envId: ID!, $path: String!, $nomadPath: String!, $nomadNamespace: String!, $credentialId: ID!) {\n createNomadSync(\n envId: $envId\n path: $path\n nomadPath: $nomadPath\n nomadNamespace: $nomadNamespace\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": types.CreateNewNomadSyncDocument,
Expand Down Expand Up @@ -155,6 +155,7 @@ const documents = {
"query GetSavedCredentials($orgId: ID!) {\n savedCredentials(orgId: $orgId) {\n id\n name\n credentials\n createdAt\n provider {\n id\n name\n expectedCredentials\n optionalCredentials\n }\n syncCount\n }\n}": types.GetSavedCredentialsDocument,
"query GetServerKey {\n serverPublicKey\n}": types.GetServerKeyDocument,
"query GetServiceList {\n services {\n id\n name\n provider {\n id\n }\n }\n}": types.GetServiceListDocument,
"query GetGithubEnvironments($credentialId: ID!, $owner: String!, $repoName: String!) {\n githubEnvironments(\n credentialId: $credentialId\n owner: $owner\n repoName: $repoName\n )\n}": types.GetGithubEnvironmentsDocument,
"query GetGithubRepos($credentialId: ID!) {\n githubRepos(credentialId: $credentialId) {\n name\n owner\n type\n }\n}": types.GetGithubReposDocument,
"query GetGitLabResources($credentialId: ID!) {\n gitlabProjects(credentialId: $credentialId) {\n id\n name\n namespace {\n name\n fullPath\n }\n pathWithNamespace\n webUrl\n }\n gitlabGroups(credentialId: $credentialId) {\n id\n fullName\n fullPath\n webUrl\n }\n}": types.GetGitLabResourcesDocument,
"query TestNomadAuth($credentialId: ID!) {\n testNomadCreds(credentialId: $credentialId)\n}": types.TestNomadAuthDocument,
Expand Down Expand Up @@ -467,7 +468,7 @@ export function graphql(source: "mutation DeleteSync($syncId: ID!) {\n deleteEn
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "mutation CreateNewGhActionsSync($envId: ID!, $path: String!, $repoName: String!, $owner: String!, $credentialId: ID!) {\n createGhActionsSync(\n envId: $envId\n path: $path\n repoName: $repoName\n owner: $owner\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}"): (typeof documents)["mutation CreateNewGhActionsSync($envId: ID!, $path: String!, $repoName: String!, $owner: String!, $credentialId: ID!) {\n createGhActionsSync(\n envId: $envId\n path: $path\n repoName: $repoName\n owner: $owner\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}"];
export function graphql(source: "mutation CreateNewGhActionsSync($envId: ID!, $path: String!, $repoName: String!, $owner: String!, $credentialId: ID!, $environmentName: String) {\n createGhActionsSync(\n envId: $envId\n path: $path\n repoName: $repoName\n owner: $owner\n credentialId: $credentialId\n environmentName: $environmentName\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}"): (typeof documents)["mutation CreateNewGhActionsSync($envId: ID!, $path: String!, $repoName: String!, $owner: String!, $credentialId: ID!, $environmentName: String) {\n createGhActionsSync(\n envId: $envId\n path: $path\n repoName: $repoName\n owner: $owner\n credentialId: $credentialId\n environmentName: $environmentName\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand Down Expand Up @@ -748,6 +749,10 @@ export function graphql(source: "query GetServerKey {\n serverPublicKey\n}"): (
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "query GetServiceList {\n services {\n id\n name\n provider {\n id\n }\n }\n}"): (typeof documents)["query GetServiceList {\n services {\n id\n name\n provider {\n id\n }\n }\n}"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "query GetGithubEnvironments($credentialId: ID!, $owner: String!, $repoName: String!) {\n githubEnvironments(\n credentialId: $credentialId\n owner: $owner\n repoName: $repoName\n )\n}"): (typeof documents)["query GetGithubEnvironments($credentialId: ID!, $owner: String!, $repoName: String!) {\n githubEnvironments(\n credentialId: $credentialId\n owner: $owner\n repoName: $repoName\n )\n}"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand Down
Loading