Skip to content
Open
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
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion crates/hm-dsl-engine/harmont-py/harmont/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from ._pipeline import pipeline_to_json
from ._python import python
from ._rust import RustProject, rust
from ._secret import SecretRef, secrets
from ._step import Step, scratch, wait
from ._target import clear_target_cache, target # noqa: F401 clear_target_cache used by tests
from ._toolchain import apt_base
Expand All @@ -58,6 +59,7 @@
from .types import Pipeline

if TYPE_CHECKING:
from collections.abc import Mapping
from datetime import timedelta


Expand Down Expand Up @@ -237,7 +239,7 @@ def sh(
cwd: str | None = None,
label: str | None = None,
cache: CachePolicy | None = None,
env: dict[str, str] | None = None,
env: Mapping[str, str | SecretRef] | None = None,
image: str | None = None,
key: str | None = None,
) -> Step:
Expand Down Expand Up @@ -309,6 +311,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]:
"JsProject",
"Pipeline",
"RustProject",
"SecretRef",
"Step",
"Target",
"apt_base",
Expand All @@ -330,6 +333,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]:
"python",
"rust",
"scratch",
"secrets",
"sh",
"target",
"timeout",
Expand Down
3 changes: 2 additions & 1 deletion crates/hm-dsl-engine/harmont-py/harmont/_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
if TYPE_CHECKING:
from collections.abc import Callable

from ._secret import SecretRef
from .triggers import Trigger

_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
Expand All @@ -33,7 +34,7 @@ def pipeline(
name: str | None = None,
triggers: tuple[Trigger, ...] | list[Trigger] = (),
allow_manual: bool = True,
env: dict[str, str] | None = None,
env: dict[str, str | SecretRef] | None = None,
timeout: str | int | None = None,
) -> Callable[[Callable[..., Any]], Callable[[], Any]]:
"""Register a function as a CI pipeline (decorator form).
Expand Down
23 changes: 19 additions & 4 deletions crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from ._duration import parse_duration
from ._keys import resolve_keys
from ._secret import SecretRef
from .cache import (
CacheCompose,
CacheForever,
Expand All @@ -37,7 +38,7 @@
def pipeline(
leaves: list[Step] | tuple[Step, ...],
*,
env: dict[str, str] | None = None,
env: dict[str, str | SecretRef] | None = None,
timeout: str | int | None = None,
) -> dict[str, Any]:
"""Top-level factory. Returns a JSON-shaped dict (version "0").
Expand Down Expand Up @@ -66,7 +67,7 @@ def pipeline(
def _lower_to_graph(
leaves: list[Step],
*,
env: dict[str, str] | None = None,
env: dict[str, str | SecretRef] | None = None,
) -> dict[str, Any]:
"""Walk back via `parent`, topo-sort, emit petgraph-serde graph dict.

Expand Down Expand Up @@ -131,7 +132,7 @@ def _lower_to_graph(
step_dict["runner_args"] = s.runner_args

# Baseline env for non-interactive operation inside VMs/containers.
merged_env: dict[str, str] = {
merged_env: dict[str, str | SecretRef] = {
"DEBIAN_FRONTEND": "noninteractive",
"TERM": "dumb",
}
Expand All @@ -140,7 +141,21 @@ def _lower_to_graph(
if s.env:
merged_env.update(s.env)

nodes.append({"step": step_dict, "env": merged_env})
# Split the merged env: literal strings stay as env; SecretRefs become
# an env-var-name -> secret-name map resolved at run time. The
# node-level `secrets` map is authoritative; the step-level copy
# mirrors the same merged map so consumers that read either see it.
literal_env: dict[str, str] = {}
secret_refs: dict[str, str] = {}
for var, val in merged_env.items():
if isinstance(val, SecretRef):
secret_refs[var] = val.name
else:
literal_env[var] = val

step_dict["secrets"] = secret_refs

nodes.append({"step": step_dict, "env": literal_env, "secrets": secret_refs})

# builds_in edge from parent.
parent_key = _resolved_parent_key(s, keys)
Expand Down
3 changes: 2 additions & 1 deletion crates/hm-dsl-engine/harmont-py/harmont/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
if TYPE_CHECKING:
from collections.abc import Callable

from ._secret import SecretRef
from .triggers import Trigger


Expand All @@ -21,7 +22,7 @@ class PipelineRegistration:
name: str
triggers: tuple[Trigger, ...]
allow_manual: bool
env: dict[str, str] | None
env: dict[str, str | SecretRef] | None
fn: Callable[[], object]
timeout: str | int | None = None

Expand Down
40 changes: 40 additions & 0 deletions crates/hm-dsl-engine/harmont-py/harmont/_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Secret references for the Harmont pipeline SDK.

`hm.secrets["NAME"]` returns a SecretRef — a *reference* to a secret, never its
value. The value is resolved at run time (locally from .env + the process
environment; in the cloud from the org/pipeline secret store). Use it anywhere
an env value is accepted: `hm.sh("deploy", env={"TOKEN": hm.secrets["DEPLOY_TOKEN"]})`.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

_SECRET_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")


@dataclass(frozen=True)
class SecretRef:
"""A reference to a stored secret, identified by name."""

name: str

def __repr__(self) -> str: # never leaks a value — there is none to leak
return f"SecretRef({self.name!r})"


class _Secrets:
"""Subscript accessor exposed as `hm.secrets`."""

def __getitem__(self, name: str) -> SecretRef:
if not isinstance(name, str) or not _SECRET_NAME.match(name):
msg = (
f"secret name {name!r} is invalid: names must match "
r"[A-Za-z_][A-Za-z0-9_]* (letters, digits, underscores; no leading digit)."
)
raise ValueError(msg)
return SecretRef(name)


secrets = _Secrets()
9 changes: 6 additions & 3 deletions crates/hm-dsl-engine/harmont-py/harmont/_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Mapping

from ._secret import SecretRef
from .cache import CachePolicy


Expand All @@ -33,7 +36,7 @@ class Step:
continue_on_failure: bool = False
label: str | None = None
cache: CachePolicy | None = None
env: dict[str, str] | None = None
env: dict[str, str | SecretRef] | None = None
timeout_seconds: int | None = None
image: str | None = None
"""Local-mode Docker base image override for this step. Ignored when
Expand All @@ -60,7 +63,7 @@ def sh(
cwd: str | None = None,
label: str | None = None,
cache: CachePolicy | None = None,
env: dict[str, str] | None = None,
env: Mapping[str, str | SecretRef] | None = None,
image: str | None = None,
runner: str | None = None,
runner_args: dict[str, Any] | None = None,
Expand Down Expand Up @@ -115,7 +118,7 @@ def sh(
parent=self,
label=label,
cache=cache,
env=env,
env=dict(env) if env is not None else None,
image=effective_image,
runner=runner,
runner_args=runner_args,
Expand Down
43 changes: 43 additions & 0 deletions crates/hm-dsl-engine/harmont-py/tests/test_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import json

import harmont as hm


def _nodes(p):
return json.loads(hm.pipeline_to_json(p))["graph"]["nodes"]


def test_secret_ref_is_repr_safe():
ref = hm.secrets["DEPLOY_TOKEN"]
assert isinstance(ref, hm.SecretRef)
assert ref.name == "DEPLOY_TOKEN"
assert "DEPLOY_TOKEN" in repr(ref)


def test_env_secret_ref_lowers_into_secrets_map():
p = hm.pipeline(
[hm.sh("deploy", env={"TOKEN": hm.secrets["DEPLOY_TOKEN"], "CI": "true"})],
)
node = _nodes(p)[0]
# NOTE: the current DSL also seeds baseline env (DEBIAN_FRONTEND, TERM).
# Assert our keys are present rather than equality on the whole dict.
assert node["env"]["CI"] == "true"
assert "TOKEN" not in node["env"]
assert node["secrets"] == {"TOKEN": "DEPLOY_TOKEN"}
assert node["step"]["secrets"] == {"TOKEN": "DEPLOY_TOKEN"}


def test_pipeline_level_secret_merges_under_step():
p = hm.pipeline(
[hm.sh("deploy", env={"TOKEN": hm.secrets["PIPELINE_TOKEN"]})],
env={"TOKEN": hm.secrets["GLOBAL_TOKEN"], "SHARED": hm.secrets["SHARED_SECRET"]},
)
node = _nodes(p)[0]
assert node["secrets"] == {"TOKEN": "PIPELINE_TOKEN", "SHARED": "SHARED_SECRET"}


def test_invalid_secret_name_rejected():
import pytest

with pytest.raises(ValueError, match="secret name"):
hm.secrets["not valid!"]
1 change: 1 addition & 0 deletions crates/hm-dsl-engine/harmont-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ export {
resolvePipelineCacheKeys,
type CacheKeyOptions,
} from "./keygen.js";
export { secrets, isSecretRef, type SecretRef } from "./secret.js";
24 changes: 21 additions & 3 deletions crates/hm-dsl-engine/harmont-ts/src/pipeline.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CachePolicy } from "./cache.js";
import { parseDuration } from "./duration.js";
import { resolveKeys } from "./keys.js";
import { isSecretRef, type SecretRef } from "./secret.js";
import type { Step } from "./step.js";

// Across-the-board default image for imageless root steps. The SDK's
Expand All @@ -9,7 +10,7 @@ import type { Step } from "./step.js";
const DEFAULT_IMAGE = "ubuntu:24.04";

export interface PipelineOptions {
readonly env?: Readonly<Record<string, string>>;
readonly env?: Readonly<Record<string, string | SecretRef>>;
readonly timeout?: string | number;
}

Expand All @@ -27,6 +28,7 @@ export interface PipelineIR {
interface GraphNode {
step: Record<string, unknown>;
env: Record<string, string>;
secrets: Record<string, string>;
}

export function pipeline(
Expand Down Expand Up @@ -93,14 +95,30 @@ function lowerToGraph(
if (s._runner != null) stepDict.runner = s._runner;
if (s._runnerArgs != null) stepDict.runner_args = s._runnerArgs;

const mergedEnv: Record<string, string> = {
const mergedEnv: Record<string, string | SecretRef> = {
DEBIAN_FRONTEND: "noninteractive",
TERM: "dumb",
};
if (opts?.env) Object.assign(mergedEnv, opts.env);
if (s._env) Object.assign(mergedEnv, s._env);

nodes.push({ step: stepDict, env: mergedEnv });
// Split the merged env: literal strings stay as env; SecretRefs become an
// env-var-name -> secret-name map resolved at run time. The node-level
// `secrets` map is authoritative; the step-level copy mirrors it so
// consumers that read either see the same map.
const literalEnv: Record<string, string> = {};
const secretRefs: Record<string, string> = {};
for (const [varName, val] of Object.entries(mergedEnv)) {
if (isSecretRef(val)) {
secretRefs[varName] = val.name;
} else {
literalEnv[varName] = val;
}
}

stepDict.secrets = secretRefs;

nodes.push({ step: stepDict, env: literalEnv, secrets: secretRefs });

const parentKey = resolvedParentKey(s, keys);
if (parentKey !== null) {
Expand Down
37 changes: 37 additions & 0 deletions crates/hm-dsl-engine/harmont-ts/src/secret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
const SECRET_BRAND = Symbol("harmont.secretRef");

/** A reference to a stored secret, identified by name. Never holds a value. */
export interface SecretRef {
readonly [SECRET_BRAND]: true;
readonly name: string;
}

export function isSecretRef(v: unknown): v is SecretRef {
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[SECRET_BRAND] === true;
}

/**
* `secrets["NAME"]` returns a SecretRef — a reference resolved at run time
* (locally from .env + the process env; in the cloud from the secret store).
*/
export const secrets: Readonly<Record<string, SecretRef>> = new Proxy(
{},
{
get(target, prop: string | symbol): SecretRef | undefined {
// Only string subscripting names a secret; symbol access defers to the
// target (so the object is not an accidental thenable, etc.).
if (typeof prop === "symbol") {
return (target as Record<symbol, never>)[prop];
}
const name = prop;
if (!SECRET_NAME.test(name)) {
throw new Error(
`secret name "${name}" is invalid: names must match [A-Za-z_][A-Za-z0-9_]* ` +
`(letters, digits, underscores; no leading digit).`,
);
}
return { [SECRET_BRAND]: true, name };
},
},
) as Readonly<Record<string, SecretRef>>;
Loading
Loading