gcpctx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gcpctx/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ # Copyright 2025 yu-iskw
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """gcpctx package."""
15
+
16
+ from __future__ import annotations
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ from gcpctx.activation import activate, deactivate
21
+ from gcpctx.config import load_config
22
+ from gcpctx.discovery import find_project_root
23
+ from gcpctx.doctor import run_doctor
24
+ from gcpctx.shell import render_shell
25
+
26
+ __all__ = [
27
+ "__version__",
28
+ "activate",
29
+ "deactivate",
30
+ "find_project_root",
31
+ "load_config",
32
+ "render_shell",
33
+ "run_doctor",
34
+ ]
gcpctx/__main__.py ADDED
@@ -0,0 +1,19 @@
1
+ # Copyright 2025 yu-iskw
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Module entrypoint for python -m gcpctx."""
15
+
16
+ from gcpctx.cli import app
17
+
18
+ if __name__ == "__main__":
19
+ app()
gcpctx/activation.py ADDED
@@ -0,0 +1,159 @@
1
+ # Copyright 2025 yu-iskw
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Activation orchestration."""
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from typing import TYPE_CHECKING
20
+
21
+ from gcpctx import gcloud as gcloud_mod
22
+ from gcpctx.approvals import (
23
+ consume_once_approval,
24
+ find_matching_approval,
25
+ prompt_for_approval,
26
+ )
27
+ from gcpctx.context_id import ContextIdInput, derive_context_id
28
+ from gcpctx.errors import ConfigNotFoundError, CredentialConflictError
29
+ from gcpctx.models import ActivationRequest, ActivationResult
30
+ from gcpctx.paths import cloudsdk_config_dir
31
+ from gcpctx.project_context import ResolvedProjectContext, resolve_project_context
32
+
33
+ if TYPE_CHECKING:
34
+ from pathlib import Path
35
+
36
+
37
+ def activate(request: ActivationRequest) -> ActivationResult:
38
+ """Activate gcpctx for the given request."""
39
+ try:
40
+ ctx = resolve_project_context(request.cwd, request.profile)
41
+ except ConfigNotFoundError:
42
+ if request.run_mode:
43
+ raise
44
+ return missing_config_result()
45
+ ctx_id = derive_context_id(
46
+ ContextIdInput(
47
+ root=ctx.root,
48
+ profile=ctx.profile_name,
49
+ project=ctx.project,
50
+ service_account=ctx.service_account,
51
+ config_sha256=ctx.config_sha256,
52
+ )
53
+ )
54
+ config_dir = cloudsdk_config_dir(ctx_id)
55
+
56
+ approval = find_matching_approval(ctx)
57
+ if approval is None:
58
+ approval = prompt_for_approval(
59
+ ctx,
60
+ cloudsdk_config=config_dir,
61
+ interactive=request.interactive,
62
+ )
63
+
64
+ warnings, unsets = _gac_policy(request)
65
+
66
+ if not request.skip_gcloud_init:
67
+ gcloud_mod.ensure_initialized(
68
+ gcloud_mod.InitContext(
69
+ context_id=ctx_id,
70
+ root=ctx.root,
71
+ profile_name=ctx.profile_name,
72
+ profile=ctx.profile,
73
+ config_sha256=ctx.config_sha256,
74
+ force=request.force_refresh,
75
+ )
76
+ )
77
+
78
+ consume_once_approval(approval)
79
+
80
+ exports = _build_exports(ctx, ctx_id, config_dir)
81
+ return ActivationResult(
82
+ active=True,
83
+ root=ctx.root,
84
+ profile=ctx.profile_name,
85
+ project=ctx.project,
86
+ service_account=ctx.service_account,
87
+ cloudsdk_config=config_dir,
88
+ context_id=ctx_id,
89
+ exports=exports,
90
+ unsets=unsets,
91
+ warnings=warnings,
92
+ )
93
+
94
+
95
+ def deactivate() -> ActivationResult:
96
+ """Return deactivation result."""
97
+ return ActivationResult(active=False)
98
+
99
+
100
+ def child_environ(
101
+ result: ActivationResult,
102
+ base: dict[str, str] | None = None,
103
+ ) -> dict[str, str]:
104
+ """Build subprocess environment from activation exports and unsets."""
105
+ env = (base or os.environ).copy()
106
+ for key in result.unsets:
107
+ env.pop(key, None)
108
+ env.update(result.exports)
109
+ return env
110
+
111
+
112
+ def missing_config_result() -> ActivationResult:
113
+ """When no .gcpctx.toml: deactivate if active, else emit no-op shell code."""
114
+ if os.environ.get("GCPCTX_ACTIVE") == "1":
115
+ return ActivationResult(active=False)
116
+ return ActivationResult(active=False, noop=True)
117
+
118
+
119
+ def _build_exports(
120
+ ctx: ResolvedProjectContext,
121
+ ctx_id: str,
122
+ config_dir: Path,
123
+ ) -> dict[str, str]:
124
+ exports: dict[str, str] = {
125
+ "GCPCTX_ACTIVE": "1",
126
+ "GCPCTX_ROOT": str(ctx.root.resolve()),
127
+ "GCPCTX_PROFILE": ctx.profile_name,
128
+ "GCPCTX_PROJECT": ctx.project,
129
+ "GCPCTX_SERVICE_ACCOUNT": ctx.service_account,
130
+ "GCPCTX_CONTEXT_ID": ctx_id,
131
+ "CLOUDSDK_CONFIG": str(config_dir),
132
+ **ctx.profile.env,
133
+ }
134
+ if ctx.profile.region:
135
+ exports["CLOUDSDK_COMPUTE_REGION"] = ctx.profile.region
136
+ if ctx.profile.zone:
137
+ exports["CLOUDSDK_COMPUTE_ZONE"] = ctx.profile.zone
138
+ if "CLOUDSDK_CORE_PROJECT" not in exports:
139
+ exports["CLOUDSDK_CORE_PROJECT"] = ctx.project
140
+ return exports
141
+
142
+
143
+ def _gac_policy(request: ActivationRequest) -> tuple[list[str], list[str]]:
144
+ if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
145
+ return [], []
146
+ warning = (
147
+ "GOOGLE_APPLICATION_CREDENTIALS is set and may override ADC; "
148
+ "it will be unset for this context"
149
+ )
150
+ if not request.interactive and not request.allow_google_application_credentials:
151
+ msg = (
152
+ "GOOGLE_APPLICATION_CREDENTIALS is set; "
153
+ "pass --allow-google-application-credentials in non-interactive mode"
154
+ )
155
+ raise CredentialConflictError(msg)
156
+ unsets: list[str] = []
157
+ if request.hook_mode or request.run_mode or not request.allow_google_application_credentials:
158
+ unsets.append("GOOGLE_APPLICATION_CREDENTIALS")
159
+ return [warning], unsets
gcpctx/approvals.py ADDED
@@ -0,0 +1,156 @@
1
+ # Copyright 2025 yu-iskw
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """First-use approval persistence and matching."""
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import TYPE_CHECKING, Literal
20
+
21
+ from rich.console import Console
22
+
23
+ from gcpctx import paths
24
+ from gcpctx.errors import ApprovalRequiredError
25
+ from gcpctx.models import ApprovalRecord, ApprovalsStore
26
+ from gcpctx.security import ensure_dir, ensure_file
27
+ from gcpctx.timeutil import utc_now_iso
28
+
29
+ if TYPE_CHECKING:
30
+ from pathlib import Path
31
+
32
+ from gcpctx.project_context import ResolvedProjectContext
33
+
34
+ ApprovalMode = Literal["once", "remembered"]
35
+
36
+
37
+ def load_store() -> ApprovalsStore:
38
+ """Load approvals from disk or return empty store."""
39
+ path = paths.approvals_file()
40
+ if not path.is_file():
41
+ return ApprovalsStore()
42
+ data = json.loads(path.read_text(encoding="utf-8"))
43
+ return ApprovalsStore.model_validate(data)
44
+
45
+
46
+ def save_store(store: ApprovalsStore) -> None:
47
+ """Persist approvals to disk."""
48
+ ensure_dir(paths.approvals_file().parent)
49
+ ensure_file(paths.approvals_file(), store.model_dump_json(indent=2))
50
+
51
+
52
+ def find_matching_approval(ctx: ResolvedProjectContext) -> ApprovalRecord | None:
53
+ """Return matching approval record if one exists."""
54
+ store = load_store()
55
+ root_str = str(ctx.root.resolve())
56
+ for record in store.approvals:
57
+ if _record_matches(record, ctx, root_str):
58
+ return record
59
+ return None
60
+
61
+
62
+ def add_approval(ctx: ResolvedProjectContext, *, mode: ApprovalMode) -> ApprovalRecord:
63
+ """Add or replace approval for this binding."""
64
+ store = load_store()
65
+ root_str = str(ctx.root.resolve())
66
+ store.approvals = [r for r in store.approvals if not _record_matches(r, ctx, root_str)]
67
+ record = ApprovalRecord(
68
+ root=root_str,
69
+ profile=ctx.profile_name,
70
+ project=ctx.project,
71
+ service_account=ctx.service_account,
72
+ config_sha256=ctx.config_sha256,
73
+ approved_at=utc_now_iso(),
74
+ mode=mode,
75
+ )
76
+ store.approvals.append(record)
77
+ save_store(store)
78
+ return record
79
+
80
+
81
+ def revoke_approval(ctx: ResolvedProjectContext) -> bool:
82
+ """Remove matching approval; return True if removed."""
83
+ store = load_store()
84
+ root_str = str(ctx.root.resolve())
85
+ before = len(store.approvals)
86
+ store.approvals = [r for r in store.approvals if not _record_matches(r, ctx, root_str)]
87
+ if len(store.approvals) == before:
88
+ return False
89
+ save_store(store)
90
+ return True
91
+
92
+
93
+ def consume_once_approval(record: ApprovalRecord) -> None:
94
+ """Remove a once-mode approval after use."""
95
+ if record.mode != "once":
96
+ return
97
+ store = load_store()
98
+ store.approvals = [
99
+ r
100
+ for r in store.approvals
101
+ if not (
102
+ r.root == record.root
103
+ and r.profile == record.profile
104
+ and r.project == record.project
105
+ and r.service_account == record.service_account
106
+ and r.config_sha256 == record.config_sha256
107
+ )
108
+ ]
109
+ save_store(store)
110
+
111
+
112
+ def prompt_for_approval(
113
+ ctx: ResolvedProjectContext,
114
+ *,
115
+ cloudsdk_config: Path,
116
+ interactive: bool,
117
+ ) -> ApprovalRecord:
118
+ """Prompt user for approval or fail closed in non-interactive mode."""
119
+ if not interactive:
120
+ msg = "approval required for activation (non-interactive mode)"
121
+ raise ApprovalRequiredError(msg)
122
+
123
+ console = Console(stderr=True)
124
+ console.print("\n[bold]gcpctx wants to activate this Google Cloud context:[/bold]\n")
125
+ console.print(f"Directory: {ctx.root}")
126
+ console.print(f"Profile: {ctx.profile_name}")
127
+ console.print(f"Project: {ctx.project}")
128
+ console.print(f"Service account: {ctx.service_account}")
129
+ console.print(f"CLOUDSDK_CONFIG: {cloudsdk_config}\n")
130
+ console.print("Approve this directory/profile/service-account binding?\n")
131
+ console.print("[A] Approve once [R] Remember approval [D] Deny")
132
+
133
+ while True:
134
+ choice = console.input("[bold cyan]Choice[/bold cyan] (A/R/D): ").strip().upper()
135
+ if choice == "D":
136
+ msg = "activation denied by user"
137
+ raise ApprovalRequiredError(msg)
138
+ if choice in {"A", "R"}:
139
+ mode: ApprovalMode = "once" if choice == "A" else "remembered"
140
+ return add_approval(ctx, mode=mode)
141
+ console.print("Invalid choice. Enter A, R, or D.")
142
+
143
+
144
+ def _record_matches(
145
+ record: ApprovalRecord,
146
+ ctx: ResolvedProjectContext,
147
+ root_str: str,
148
+ ) -> bool:
149
+ return (
150
+ record.root == root_str
151
+ and record.profile == ctx.profile_name
152
+ and record.project == ctx.project
153
+ and record.service_account == ctx.service_account
154
+ and record.config_sha256 == ctx.config_sha256
155
+ and record.schema_version == 1
156
+ )