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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: ci

on:
pull_request:
push:
branches: [main]

jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Lint
# Scoped to the files this plan adds or touches. The pre-existing
# gitlab/ and asset-repo/ scripts are NOT clean under shellcheck; fixing
# them is out of scope here and would bury this change in noise.
#
# SC1091: shellcheck cannot follow `source "$CURRENT_DIR/_api"`.
# SC2154: the workflow engine supplies CODE_REPOSITORY_STRATEGY,
# REPOSITORY_NAME, APPLICATION_ID, ... from the environment, so
# they are legitimately "referenced but not assigned" here.
run: |
shellcheck --shell=bash -e SC1091 -e SC2154 \
scripts/code-repo/bitbucket/* \
scripts/code-repo/generate_secrets \
tests/run \
tests/lib.sh \
tests/stubs/*

tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run script tests
run: tests/run
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to `application-lifecycle-manager` will be documented in thi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches a stable API.

## [0.3.0] - 2026-07-13

### Added
- Bitbucket Cloud code repository support (`scripts/code-repo/bitbucket`), authenticated with a
dedicated bot user's Atlassian API token over HTTP Basic (the only credential that can enable
Bitbucket Pipelines).
- A `NULLPLATFORM_API_KEY` pipeline secret, alongside the existing `NP_API_KEY`, so the shared
technology templates work under either provisioning path.
- An offline test harness (`tests/run`) and CI running `shellcheck` plus those tests.


## [0.2.0] - 2025-11-13

### Added
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,51 @@ When an application is created, this service coordinates two main tasks:

You must configure your code repository provider through **nullplatform platform settings** or the **nullplatform Terraform provider**.

> **Note:** At the moment, this repository supports **GitLab** repositories only.
> **Note:** This repository supports **GitLab** and **Bitbucket Cloud** repositories.

#### Bitbucket Cloud

Configure a `bitbucket-configuration` code-repository provider with:

| Attribute (provider `setup`) | NRN key | Required | Source | Notes |
|---|---|---|---|---|
| `workspace` | `bitbucket.workspace` | yes | provider or env | The Bitbucket workspace slug. |
| `project_key` | `bitbucket.projectKey` | yes | provider or env | The key of the project new repositories are filed under. **Not optional**: omit it and Bitbucket silently assigns the repository to the workspace's oldest project. |
| `email` | `bitbucket.email` | yes | provider or env | The Atlassian account email of the dedicated Bitbucket **bot user**. HTTP Basic username for the API token. |
| `api_token` | `bitbucket.apiToken` | yes | **`BITBUCKET_API_TOKEN` env only** | The bot user's **Atlassian API token** — HTTP Basic password for the REST API and, with the git username `x-bitbucket-api-token-auth`, for git-over-HTTPS. See "credential sourcing" below. |
| `installation_url` | `bitbucket.installationUrl` | no | provider or env | Defaults to `https://bitbucket.org`. |
| `access.default_collaborators` | — | no | provider | See the limitation below. |

> **Authentication is a dedicated bot user's Atlassian API token.** nullplatform must hold a
> **user-scoped** credential because enabling Bitbucket Pipelines requires a two-step-verification
> (2SV) enabled *user* principal — an OAuth app (2LO) is refused there with a permanent `403`, and a
> workspace access token fails the same check. So: create a dedicated Bitbucket bot user, **enable
> Bitbucket 2SV on it** (this is Bitbucket 2SV, *not* Atlassian-account 2FA), grant it repository /
> pipeline / project admin on the workspace, and issue it an Atlassian API token. This works on
> **every** Bitbucket plan (no Premium required). Atlassian API tokens expire in ≤365 days, so rotate
> it before then. **Bitbucket app passwords are not supported** (Atlassian removed them on 2026-07-28).

> **Credential sourcing — this is where Bitbucket differs from GitLab.** The API token is stored as a
> proper **secret** (the `bitbucket-configuration` spec marks `api_token` `secret: true`), and
> nullplatform **does not return secret attribute values on authenticated provider reads**. So `np
> provider list` — which ALM uses to load provider config — returns `api_token: null`; the platform
> read *cannot* hand ALM the token. (The GitLab provider only works from the platform read because its
> spec never marked `access_token` secret — a laxity not repeated here.) **Therefore the token's
> primary, documented source is the `BITBUCKET_API_TOKEN` environment variable set on the ALM
> deployment** (`build_context` also honours `BITBUCKET_EMAIL`, `BITBUCKET_WORKSPACE`,
> `BITBUCKET_PROJECT_KEY`, `BITBUCKET_INSTALLATION_URL`). The non-secret fields above are still read
> from the provider record. If the token is absent from both env and record, provisioning fails
> immediately with a message explaining exactly this. *(`np nrn read` can address raw NRN keys but is
> deprecated and is not relied upon here.)*

**Limitation — collaborators must already be workspace members.** Bitbucket has no API to
invite a user to a workspace, so `add_collaborators` can only *grant repository permissions* to
principals who are already members. If a configured collaborator is not a member, repository
creation fails with an error naming them; a workspace administrator must invite them first.

**Limitation — no template import API.** Bitbucket Cloud cannot import a repository from a URL,
and forking is no longer usable. Templates are therefore seeded with a real `git clone` + `git
push`, squashed to a single initial commit. **`git` must be available on the agent host.**

#### Workflow

Expand Down
119 changes: 119 additions & 0 deletions scripts/code-repo/bitbucket/_api
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/bin/bash
#
# Shared Bitbucket Cloud REST helpers. Sourced by the step scripts in this
# directory; the leading underscore marks it as "not a workflow step".
#
# IMPORTANT -- every script in this repository is SOURCED by
# `np service workflow exec`, not executed. (Proof: gitlab/create_repository
# uses a bare top-level `return`, and none of these files carry an executable
# bit.) Consequences:
#
# * NEVER `set -euo pipefail` here. It would leak into the workflow shell and
# silently change the behaviour of every later step.
# * `exit 1` aborts the sourcing shell -- that IS how a step fails the
# workflow, and entrypoint then reports "failed" back to nullplatform.
# * `return` skips the rest of a step without failing the workflow.
#
# AUTH: nullplatform authenticates to Bitbucket Cloud with a single dedicated
# bot user's Atlassian API token, using HTTP Basic (email:api_token). This is the
# ONLY credential that can enable Bitbucket Pipelines -- an OAuth app (2LO) is
# refused there with a permanent 403, and a workspace access token fails the same
# 2SV check (both validated live). build_context resolves the email + token and
# exports them; every REST call here sends them as Basic auth, and git transport
# uses the username `x-bitbucket-api-token-auth` with the token as the password.

# bb_api METHOD PATH [BODY]
#
# Calls the Bitbucket REST API and sets BB_HTTP_CODE and BB_BODY. It never
# fails: every caller branches on the status code, because for Bitbucket the
# code is the meaning (404 = "free" on one endpoint, "never enabled" on
# another; 409 = "exists"; 401 != "missing").
bb_api() {
local method="$1"
local path="$2"
local body="${3:-}"

local tmp
tmp=$(mktemp)

local args=(
-s
-o "$tmp"
-w '%{http_code}'
-X "$method"
-u "${BITBUCKET_EMAIL}:${BITBUCKET_API_TOKEN}"
-H "Content-Type: application/json"
)

if [[ -n "$body" ]]; then
args+=(--data "$body")
fi

# BB_HTTP_CODE and BB_BODY are the outputs of bb_api: they are read by the
# step scripts that source this file, so they are not unused here.
# shellcheck disable=SC2034
BB_HTTP_CODE=$(curl "${args[@]}" "${BITBUCKET_API_BASE}${path}")
# shellcheck disable=SC2034
BB_BODY=$(cat "$tmp")

rm -f "$tmp"
}

# bb_encode_uuid VALUE
#
# Bitbucket returns UUIDs with LITERAL braces -- {9484702e-...} -- and they must
# be percent-encoded before use as a path parameter.
bb_encode_uuid() {
local value="$1"

value="${value//\{/%7B}"
value="${value//\}/%7D}"

printf '%s' "$value"
}

# bb_scrub
#
# stdin filter that redacts the API token. git prints credentialed remote URLs
# verbatim on failure, so every byte of git output goes through this. (The bot
# email is the Basic-auth username and the git URL's userinfo is the fixed
# literal `x-bitbucket-api-token-auth`; the token is the only secret.)
bb_scrub() {
if [[ -z "${BITBUCKET_API_TOKEN:-}" ]]; then
cat
return
fi

local escaped
# Escape regex metacharacters (including /) so any token value is literal.
escaped=$(printf '%s' "$BITBUCKET_API_TOKEN" | sed -e 's/[]\/$*.^[]/\\&/g')

sed -e "s/${escaped}/***/g"
}

# bb_git ARGS...
#
# Runs git with the terminal prompt disabled, scrubbing the token out of its
# output, and returns GIT's exit status -- not sed's. (A naive
# `git ... | bb_scrub` would always look successful, because a pipeline's status
# is its LAST command. We cannot use `set -o pipefail` to fix that: see the
# header.)
bb_git() {
local output
local status

output=$(GIT_TERMINAL_PROMPT=0 git "$@" 2>&1)
status=$?

if [[ -n "$output" ]]; then
printf '%s\n' "$output" | bb_scrub
fi

return $status
}

# bb_fail MESSAGE...
bb_fail() {
echo "Error: $*" >&2
exit 1
}
88 changes: 88 additions & 0 deletions scripts/code-repo/bitbucket/add_collaborators
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/bin/bash

CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}")

# shellcheck source=scripts/code-repo/bitbucket/_api
source "$CURRENT_DIR/_api"

# Bitbucket repository permissions are read | write | admin. Accept those
# directly, and map the GitLab role names the platform already stores.
get_permission() {
local role="$1"

case "$role" in
read | guest | reporter) echo "read" ;;
write | developer | maintainer) echo "write" ;;
admin | owner) echo "admin" ;;
*) echo "write" ;; # mirrors the GitLab provider's default of DEVELOPER
esac
}

MISSING_MEMBERS=()
FAILED=0

# Process substitution, NOT a pipe. `... | while read` would run the loop body in
# a SUBSHELL, where FAILED and MISSING_MEMBERS would be discarded and an `exit 1`
# would kill only the subshell -- which is exactly why the GitLab equivalents of
# this script and of create_secrets can never actually fail the workflow.
while read -r collaborator; do
[[ -z "$collaborator" ]] && continue

id=$(echo "$collaborator" | jq -r '.id')
role=$(echo "$collaborator" | jq -r '.role')
type=$(echo "$collaborator" | jq -r '.type')

permission=$(get_permission "$role")
body=$(jq -nc --arg permission "$permission" '{permission: $permission}')

if [[ "$type" == "user" ]]; then
# Users are addressed by account_id or UUID (usernames were removed for
# GDPR). UUIDs arrive with literal braces and must be percent-encoded.
encoded=$(bb_encode_uuid "$id")

echo "Granting '$permission' on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} to user '$id'"

bb_api PUT "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/permissions-config/users/${encoded}" "$body"
elif [[ "$type" == "group" ]]; then
echo "Granting '$permission' on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} to group '$id'"

bb_api PUT "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/permissions-config/groups/${id}" "$body"
else
echo "Error: unknown collaborator type '$type' for '$id'"
FAILED=1
continue
fi

case "$BB_HTTP_CODE" in
200 | 201 | 204)
echo "Granted '$permission' to '$id'"
;;
404)
# ADR-3. repository:admin can only grant permissions to principals that
# are ALREADY workspace members, and Bitbucket has no API to invite one.
# Collect these and report them together, by name, below.
MISSING_MEMBERS+=("$type '$id'")
FAILED=1
;;
*)
echo "Error granting '$permission' to '$id' (HTTP $BB_HTTP_CODE): $BB_BODY"
FAILED=1
;;
esac
done < <(echo "$CODE_REPOSITORY_COLLABORATORS" | jq -c '.[]?' 2>/dev/null)

if [[ ${#MISSING_MEMBERS[@]} -gt 0 ]]; then
echo "Error: the following collaborators are not members of the '$BITBUCKET_WORKSPACE' Bitbucket workspace:" >&2

for member in "${MISSING_MEMBERS[@]}"; do
echo " - $member" >&2
done

echo "Bitbucket has no API to invite a user to a workspace, so nullplatform cannot add them for you. A workspace administrator must invite them at ${BITBUCKET_INSTALLATION_URL}/${BITBUCKET_WORKSPACE}/workspace/settings/members first. The repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} has already been created: once they are members, re-run this application creation with the 'import' strategy." >&2
fi

if [[ "$FAILED" -ne 0 ]]; then
exit 1
fi

echo "Finished adding collaborators"
Loading
Loading