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 +34 -0
- gcpctx/__main__.py +19 -0
- gcpctx/activation.py +159 -0
- gcpctx/approvals.py +156 -0
- gcpctx/cli.py +438 -0
- gcpctx/config.py +170 -0
- gcpctx/context_id.py +51 -0
- gcpctx/discovery.py +40 -0
- gcpctx/doctor.py +241 -0
- gcpctx/errors.py +64 -0
- gcpctx/gcloud.py +227 -0
- gcpctx/logging.py +23 -0
- gcpctx/models.py +125 -0
- gcpctx/paths.py +58 -0
- gcpctx/project_context.py +68 -0
- gcpctx/py.typed +0 -0
- gcpctx/runner.py +24 -0
- gcpctx/security.py +93 -0
- gcpctx/shell.py +120 -0
- gcpctx/timeutil.py +23 -0
- gcpctx-0.1.0.dist-info/METADATA +453 -0
- gcpctx-0.1.0.dist-info/RECORD +25 -0
- gcpctx-0.1.0.dist-info/WHEEL +4 -0
- gcpctx-0.1.0.dist-info/entry_points.txt +2 -0
- gcpctx-0.1.0.dist-info/licenses/LICENSE +201 -0
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
|
+
)
|