Skip to content
Closed
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
66 changes: 66 additions & 0 deletions docs/reference/organization/repository/environment_secret.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
Definition of a `Secret` on environment level, the following properties are supported:

| Key | Value | Description | Note |
|-------------------------|----------------|------------------------------------------------|------|
| _name_ | string | The name of the secret | |
| _value_ | string | The secret value | |

The secret value can be resolved via a credential provider. The supported format is `<credential_provider>:<provider specific data>`.

- Bitwarden: `bitwarden:<bitwarden item id>@<custom_field_key>`

``` json
"secret": "bitwarden:118276ad-158c-4720-b68d-af8c00fe3481@secret"
```

- Pass: `pass:<path/to/secret>`

``` json
"secret": "pass:path/to/repo/secret"
```

!!! note

After executing an `import` operation, the secret will be set to `********` as GitHub will not disclose the
secret value anymore via its API. You will need to update the configuration with the real secret value, either
by entering the secret value (not advised), or referencing it via a credential provider.

Secrets which have a redacted value defined will be skipped during processing.

## Jsonnet Function

``` jsonnet
orgs.newEnvironmentSecret('<name>') {
<key>: <value>
}
```

## Validation rules

- redacted secret values (`********`) trigger a validation info and will skip the secret during processing

## Example usage

=== "jsonnet"
``` jsonnet
orgs.newOrg('OtterdogTest') {
...
_repositories+:: [
...
orgs.newRepo('test-repo') {
...

environments: [
orgs.newEnvironment('linux') {
secrets+: [
orgs.newEnvironmentSecret('LICENSE_KEY') {
value: "pass:path/to/secret",
},w
]
},
]

}
]
}
```
44 changes: 44 additions & 0 deletions docs/reference/organization/repository/environment_variable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
Definition of a `Variable` on environment level, the following properties are supported:

| Key | Value | Description | Note |
|-------------------------|----------------|--------------------------|------|
| _name_ | string | The name of the variable | |
| _value_ | string | The variable value | |

## Jsonnet Function

``` jsonnet
orgs.newEnvironmentVariable('<name>') {
<key>: <value>
}
```

## Validation rules

- None

## Example usage

=== "jsonnet"
``` jsonnet
orgs.newOrg('OtterdogTest') {
...
_repositories+:: [
...
orgs.newRepo('test-repo') {
...

environments: [
orgs.newEnvironment('linux') {
secrets+: [
orgs.newEnvironmentVariable('LICENSE_SEREVER') {
value: "127.0.0.1",
},w
]
},
]

}
]
}
```
2 changes: 1 addition & 1 deletion docs/reference/organization/repository/secret.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ orgs.newRepoSecret('<name>') {
orgs.newRepo('test-repo') {
...
secrets+: [
orgs.newRepoSecret('TEST_SECRET') {
orgs.newRepoSecret('REPO_SECRET') {
value: "pass:path/to/secret",
},
],
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/organization/repository/variable.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ orgs.newRepoVariable('<name>') {
orgs.newRepo('test-repo') {
...
variables+: [
orgs.newRepoVariable('TEST_VARIABLE') {
orgs.newRepoVariable('REPO_VARIABLE') {
value: "TESTVALUE",
},
],
Expand Down
12 changes: 12 additions & 0 deletions examples/template/otterdog-defaults.libsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ local newOrgSecret(name) = newRepoSecret(name) {
selected_repositories: [],
};

# Function to create a new environment secret with default settings.
local newEnvironmentSecret(name) = {
name: name,
value: null,
};

# Function to create a new repository variable with default settings.
local newRepoVariable(name) = {
name: name,
Expand All @@ -242,6 +248,12 @@ local newOrgVariable(name) = newRepoVariable(name) {
selected_repositories: [],
};

# Function to create a new environment variable with default settings.
local newEnvironmentVariable(name) = {
name: name,
value: null,
};

# Function to create a new organization role with default settings.
local newOrgRole(name) = {
name: name,
Expand Down
2 changes: 2 additions & 0 deletions otterdog/jsonnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class JsonnetConfig:
create_branch_protection_rule = "newBranchProtectionRule"
create_repo_ruleset = "newRepoRuleset"
create_environment = "newEnvironment"
create_environment_secret = "newEnvironmentSecret"
create_environment_variable = "newEnvironmentVariable"
create_pull_request = "newPullRequest"
create_status_checks = "newStatusChecks"
create_merge_queue = "newMergeQueue"
Expand Down
69 changes: 67 additions & 2 deletions otterdog/models/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@
from otterdog.utils import expect_type, is_set_and_valid, is_unset, unwrap

if TYPE_CHECKING:
from collections.abc import Iterator

from otterdog.jsonnet import JsonnetConfig
from otterdog.models.environment_secret import EnvironmentSecret
from otterdog.models.environment_variable import EnvironmentVariable
from otterdog.models.repository import Repository
from otterdog.providers.github import GitHubProvider


Expand All @@ -41,10 +46,35 @@ class Environment(ModelObject):
deployment_branch_policy: str
branch_policies: list[str]

# internal parent tracking (model-only, not serialized)
parent_repository: Repository | None = dataclasses.field(default=None, metadata={"model_only": True})

# nested model fields
variables: list[EnvironmentVariable] = dataclasses.field(metadata={"nested_model": True}, default_factory=list)
secrets: list[EnvironmentSecret] = dataclasses.field(metadata={"nested_model": True}, default_factory=list)

@property
def model_object_name(self) -> str:
return "environment"

def add_variable(self, variable: EnvironmentVariable) -> None:
self.variables.append(variable)

def get_variable(self, name: str) -> EnvironmentVariable | None:
return next(filter(lambda x: x.name == name, self.variables), None)

def set_variables(self, variables: list[EnvironmentVariable]) -> None:
self.variables = variables

def add_secret(self, secret: EnvironmentSecret) -> None:
self.secrets.append(secret)

def get_secret(self, name: str) -> EnvironmentSecret | None:
return next(filter(lambda x: x.name == name, self.secrets), None)

def set_secrets(self, secrets: list[EnvironmentSecret]) -> None:
self.secrets = secrets

def validate(self, context: ValidationContext, parent_object: Any) -> None:
if not is_unset(self.wait_timer) and not (0 <= self.wait_timer <= 43200):
context.add_failure(
Expand Down Expand Up @@ -96,15 +126,17 @@ def include_existing_object_for_live_patch(self, org_id: str, parent_object: Mod

@classmethod
def get_mapping_from_provider(cls, org_id: str, data: dict[str, Any]) -> dict[str, Any]:
"""Construct Environment object from json data returned by GitHub API."""

mapping = super().get_mapping_from_provider(org_id, data)

def transform_reviewers(x):
match x["type"]:
case "User":
return f'@{x["reviewer"]["login"]}'
return f"@{x['reviewer']['login']}"

case "Team":
return f'@{org_id}/{x["reviewer"]["slug"]}'
return f"@{org_id}/{x['reviewer']['slug']}"

case _:
raise RuntimeError("unexpected review type '{x[\"type\"]}'")
Expand Down Expand Up @@ -147,6 +179,23 @@ def transform_branch_policy(x):
)
return mapping

@classmethod
def get_mapping_from_model(cls) -> dict[str, Any]:
# Overriding get_mapping_from_model is required to handle nested models
from otterdog.models.environment_secret import EnvironmentSecret
from otterdog.models.environment_variable import EnvironmentVariable

mapping = super().get_mapping_from_model()
mapping.update(
{
"variables": OptionalS("variables", default=[])
>> Forall(lambda x: EnvironmentVariable.from_model_data(x)),
"secrets": OptionalS("secrets", default=[]) >> Forall(lambda x: EnvironmentSecret.from_model_data(x)),
}
)

return mapping

@classmethod
async def get_mapping_to_provider(
cls, org_id: str, data: dict[str, Any], provider: GitHubProvider
Expand Down Expand Up @@ -192,6 +241,22 @@ async def get_mapping_to_provider(
def get_jsonnet_template_function(self, jsonnet_config: JsonnetConfig, extend: bool) -> str | None:
return f"orgs.{jsonnet_config.create_environment}"

def get_model_objects(self) -> Iterator[tuple[ModelObject, ModelObject]]:
"""
Report any nested model objects for nested processing.

Especially get_mapping_to_provider iterates over self, and not over the nested
model objects, so we need to report them here for processing.
"""

for secret in self.secrets:
yield secret, self
yield from secret.get_model_objects()

for variable in self.variables:
yield variable, self
yield from variable.get_model_objects()

@classmethod
async def apply_live_patch(cls, patch: LivePatch[Environment], org_id: str, provider: GitHubProvider) -> None:
from .repository import Repository
Expand Down
79 changes: 79 additions & 0 deletions otterdog/models/environment_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# *******************************************************************************
# Copyright (c) 2023-2025 Eclipse Foundation and others.
# This program and the accompanying materials are made available
# under the terms of the Eclipse Public License 2.0
# which is available at http://www.eclipse.org/legal/epl-v20.html
# SPDX-License-Identifier: EPL-2.0
# *******************************************************************************

from __future__ import annotations

import dataclasses
from typing import TYPE_CHECKING

from otterdog.models import LivePatchType
from otterdog.models.environment import Environment
from otterdog.models.repository import Repository
from otterdog.models.secret import Secret
from otterdog.utils import expect_type, unwrap

if TYPE_CHECKING:
from otterdog.jsonnet import JsonnetConfig
from otterdog.models import LivePatch
from otterdog.providers.github import GitHubProvider


@dataclasses.dataclass
class EnvironmentSecret(Secret):
"""
Represents a Secret defined on environment level.
"""

@property
def model_object_name(self) -> str:
return "environment_secret"

def get_jsonnet_template_function(self, jsonnet_config: JsonnetConfig, extend: bool) -> str | None:
return f"orgs.{jsonnet_config.create_environment_secret}"

@classmethod
async def apply_live_patch( # type: ignore[override]
cls,
patch: LivePatch[EnvironmentSecret],
org_id: str,
provider: GitHubProvider,
) -> None:
environment = expect_type(patch.parent_object, Environment)
repository = expect_type(environment.parent_repository, Repository)

match patch.patch_type:
case LivePatchType.ADD:
new_object = unwrap(patch.expected_object)
data = await new_object.to_provider_data(org_id, provider)
await provider.create_environment_secret(
org_id,
repository.name,
environment.name,
data,
)

case LivePatchType.REMOVE:
remove_object = unwrap(patch.current_object)
await provider.delete_environment_secret(
org_id,
repository.name,
environment.name,
remove_object.name,
)

case LivePatchType.CHANGE:
current_obj: EnvironmentSecret = unwrap(patch.current_object)
expected_obj: EnvironmentSecret = unwrap(patch.expected_object)
data = await expected_obj.to_provider_data(org_id, provider)
await provider.update_environment_secret(
org_id,
repository.name,
environment.name,
current_obj.name,
data,
)
Loading
Loading