diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6e295783..7049344d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,5 @@ ## Unreleased - ### Added - Added new `--bundle` flag to the `operator-sdk scorecard` command to support bundle validation testing using the validation API (https://github.com/operator-framework/api). ([#1916](https://github.com/operator-framework/operator-sdk/pull/1916) @@ -12,6 +11,8 @@ - Replace usage of `github.com/operator-framework/operator-sdk/pkg/restmapper.DynamicRESTMapper` with `sigs.k8s.io/controller-runtime/pkg/client/apiutil.DynamicRESTMapper`. ([#2309](https://github.com/operator-framework/operator-sdk/pull/2309)) - Upgraded Helm operator packages and base image from Helm v2 to Helm v3. Cluster state for pre-existing CRs using Helm v2-based operators will be automatically migrated to Helm v3's new release storage format, and existing releases may be upgraded due to changes in Helm v3's label injection. ([#2080](https://github.com/operator-framework/operator-sdk/pull/2080)) - Fail `operator-sdk olm-catalog gen-csv` if it is not run from a project's root, which the command already assumes is the case. ([#2322](https://github.com/operator-framework/operator-sdk/pull/2322)) +- **Breaking Change:** Extract custom Ansible module `k8s_status`, which is now provided by the `operator_sdk.util` Ansible collection. See [developer_guide](https://github.com/operator-framework/operator-sdk/blob/master/doc/ansible/dev/developer_guide.md#custom-resource-status-management) for new usage. ([#2310](https://github.com/operator-framework/operator-sdk/pull/2310)) +- Upgrade minimal Ansible version in the init projects from `2.6` to `2.9` for collections support. ([#2310](https://github.com/operator-framework/operator-sdk/pull/2310)) ### Deprecated diff --git a/ci/dockerfiles/ansible-e2e-hybrid.Dockerfile b/ci/dockerfiles/ansible-e2e-hybrid.Dockerfile index 52cbcb4728..aaaa4c1298 100644 --- a/ci/dockerfiles/ansible-e2e-hybrid.Dockerfile +++ b/ci/dockerfiles/ansible-e2e-hybrid.Dockerfile @@ -36,10 +36,10 @@ RUN yum clean all && rm -rf /var/cache/yum/* \ && yum remove -y gcc python36-devel \ && yum clean all \ && rm -rf /var/cache/yum + && ansible-galaxy collection install operator_sdk.util # install operator binary COPY --from=builder /memcached-operator ${OPERATOR} -COPY --from=builder /go/src/github.com/operator-framework/operator-sdk/library/k8s_status.py /usr/share/ansible/openshift/ COPY --from=builder /go/src/github.com/operator-framework/operator-sdk/bin/* /usr/local/bin/ COPY --from=builder /ansible/memcached-operator/watches.yaml ${HOME}/watches.yaml COPY --from=builder /ansible/memcached-operator/roles/ ${HOME}/roles/ diff --git a/ci/dockerfiles/ansible.Dockerfile b/ci/dockerfiles/ansible.Dockerfile index f072556be3..7f9c279ca5 100644 --- a/ci/dockerfiles/ansible.Dockerfile +++ b/ci/dockerfiles/ansible.Dockerfile @@ -35,9 +35,9 @@ RUN yum clean all && rm -rf /var/cache/yum/* \ && yum remove -y gcc python36-devel \ && yum clean all \ && rm -rf /var/cache/yum + && ansible-galaxy collection install operator_sdk.util COPY --from=builder /go/src/github.com/operator-framework/operator-sdk/build/operator-sdk ${OPERATOR} -COPY --from=builder /go/src/github.com/operator-framework/operator-sdk/library/k8s_status.py /usr/share/ansible/openshift/ COPY --from=builder /go/src/github.com/operator-framework/operator-sdk/bin/* /usr/local/bin/ RUN /usr/local/bin/user_setup diff --git a/cmd/operator-sdk/migrate/cmd.go b/cmd/operator-sdk/migrate/cmd.go index 7c14735117..67ddf5d900 100644 --- a/cmd/operator-sdk/migrate/cmd.go +++ b/cmd/operator-sdk/migrate/cmd.go @@ -123,7 +123,6 @@ func migrateAnsible() error { &dockerfile, &ansible.Entrypoint{}, &ansible.UserSetup{}, - &ansible.K8sStatus{}, &ansible.AoLogs{}, ) if err != nil { diff --git a/doc/ansible/dev/developer_guide.md b/doc/ansible/dev/developer_guide.md index b9d16d7f79..f998aa8184 100644 --- a/doc/ansible/dev/developer_guide.md +++ b/doc/ansible/dev/developer_guide.md @@ -364,13 +364,17 @@ status: type: Running ``` -Ansible Operator also allows you as the developer to supply custom status -values with the [k8s_status][k8s_status_module] Ansible Module. This allows the -developer to update the `status` from within Ansible with any key/value pair as -desired. By default, Ansible Operator will always include the generic Ansible -run output as shown above. If you would prefer your application *not* update -the status with Ansible output and would prefer to track the status manually -from your application, then simply update the watches file with `manageStatus`: +Ansible Operator also allows you as the developer to supply custom +status values with the `k8s_status` Ansible Module, which is included in +[operator_sdk util collection](https://galaxy.ansible.com/operator_sdk/util). + +This allows the developer to update the `status` from within Ansible +with any key/value pair as desired. By default, Ansible Operator will +always include the generic Ansible run output as shown above. If you +would prefer your application *not* update the status with Ansible +output and would prefer to track the status manually from your +application, then simply update the watches file with `manageStatus`: + ```yaml - version: v1 group: api.example.com @@ -379,10 +383,13 @@ from your application, then simply update the watches file with `manageStatus`: manageStatus: false ``` -To update the `status` subresource with key `foo` and value `bar`, `k8s_status` -can be used as shown: +The simplest way to invoke the `k8s_status` module is to +use its fully qualified collection name (fqcn). To update the +`status` subresource with key `foo` and value `bar`, `k8s_status` can be +used as shown: + ```yaml -- k8s_status: +- operator_sdk.util.k8s_status: api_version: app.example.com/v1 kind: Foo name: "{{ meta.name }}" @@ -391,6 +398,24 @@ can be used as shown: foo: bar ``` +Collections can also be declared in the role's `meta/main.yml`, which is +included for new scaffolded ansible operators. + +```yaml +collections: + - operator_sdk.util +``` + +Declaring collections in the role meta allows you to invoke the +`k8s_status` module directly. + +```yaml +- k8s_status: + + status: + foo: bar +``` + ### Ansible Operator Conditions The Ansible Operator has a set of conditions which it will use as it performs its reconciliation procedure. There are only a few main conditions: @@ -423,23 +448,12 @@ the Ansible Operator, see the [proposal for user-driven status management][manage_status_proposal]. If your operator takes advantage of the `k8s_status` Ansible module and you are -interested in testing the operator with `operator-sdk up local`, then it is -imperative that the module is installed in a location that Ansible expects. -This is done with the `library` configuration option for Ansible. For our -example, we will assume the user is placing third-party Ansible modules in -`/usr/share/ansible/library`. - -To install the `k8s_status` module, first set `ansible.cfg` to search in -`/usr/share/ansible/library` for installed Ansible modules: -```bash -$ echo "library=/usr/share/ansible/library/" >> /etc/ansible/ansible.cfg -``` - -Add `k8s_status.py` to `/usr/share/ansible/library/`: -```bash -$ wget https://raw.githubusercontent.com/fabianvf/ansible-k8s-status-module/master/k8s_status.py -O /usr/share/ansible/library/k8s_status.py -``` +interested in testing the operator with `operator-sdk up local`, then +you will need to install the collection locally. +```sh +$ ansible-galaxy collection install operator_sdk.util + ``` ## Extra vars sent to Ansible The extra vars that are sent to Ansible are managed by the operator. The `spec` section will pass along the key-value pairs as extra vars. This is equivalent @@ -486,7 +500,6 @@ operator. The `meta` fields can be accesses via dot notation in Ansible as so: ``` [k8s_ansible_module]:https://docs.ansible.com/ansible/2.6/modules/k8s_module.html -[k8s_status_module]:https://github.com/fabianvf/ansible-k8s-status-module [openshift_restclient_python]:https://github.com/openshift/openshift-restclient-python [ansible_operator_user_guide]:../user-guide.md [manage_status_proposal]:../../proposals/ansible-operator-status.md diff --git a/hack/image/ansible/scaffold-ansible-image.go b/hack/image/ansible/scaffold-ansible-image.go index 341e1ec5c0..146f522f41 100644 --- a/hack/image/ansible/scaffold-ansible-image.go +++ b/hack/image/ansible/scaffold-ansible-image.go @@ -38,7 +38,6 @@ func main() { &ansible.DockerfileHybrid{}, &ansible.Entrypoint{}, &ansible.UserSetup{}, - &ansible.K8sStatus{}, &ansible.AoLogs{}, ) if err != nil { diff --git a/internal/scaffold/ansible/dockerfilehybrid.go b/internal/scaffold/ansible/dockerfilehybrid.go index b26f018556..16b94d0c98 100644 --- a/internal/scaffold/ansible/dockerfilehybrid.go +++ b/internal/scaffold/ansible/dockerfilehybrid.go @@ -74,11 +74,11 @@ RUN yum clean all && rm -rf /var/cache/yum/* \ jmespath \ && yum remove -y gcc python36-devel \ && yum clean all \ - && rm -rf /var/cache/yum + && rm -rf /var/cache/yum \ + && ansible-galaxy collection install operator_sdk.util COPY build/_output/bin/[[.ProjectName]] ${OPERATOR} COPY bin /usr/local/bin -COPY library/k8s_status.py /usr/share/ansible/openshift/ RUN /usr/local/bin/user_setup diff --git a/internal/scaffold/ansible/k8s_status.go b/internal/scaffold/ansible/k8s_status.go deleted file mode 100644 index f964827288..0000000000 --- a/internal/scaffold/ansible/k8s_status.go +++ /dev/null @@ -1,443 +0,0 @@ -// Copyright 2018 The Operator-SDK Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ansible - -import ( - "github.com/operator-framework/operator-sdk/internal/scaffold/input" -) - -const K8sStatusPythonFile = "library/k8s_status.py" - -// K8sStatus - the k8s status module tmpl wrapper -type K8sStatus struct { - StaticInput -} - -// GetInput - gets the input -func (k *K8sStatus) GetInput() (input.Input, error) { - if k.Path == "" { - k.Path = K8sStatusPythonFile - } - - k.TemplateBody = k8sStatusTmpl - - return k.Input, nil -} - -const k8sStatusTmpl = `#!/usr/bin/python3 -# -*- coding: utf-8 -*- - -from __future__ import absolute_import, division, print_function - -import re -import copy - -from ansible.module_utils.k8s.common import AUTH_ARG_SPEC, COMMON_ARG_SPEC, KubernetesAnsibleModule - -try: - from openshift.dynamic.exceptions import DynamicApiError -except ImportError as exc: - class KubernetesException(Exception): - pass - - -__metaclass__ = type - -ANSIBLE_METADATA = {'metadata_version': '1.1', - 'status': ['preview'], - 'supported_by': 'community'} - -DOCUMENTATION = ''' - -module: k8s_status - -short_description: Update the status for a Kubernetes API resource - -version_added: "2.7" - -author: "Fabian von Feilitzsch (@fabianvf)" - -description: - - Sets the status field on a Kubernetes API resource. Only should be used if you are using Ansible to - implement a controller for the resource being modified. - -options: - status: - type: dict - description: - - A object containing ` + "`key: value`" + ` pairs that will be set on the status object of the specified resource. - - One of I(status) or I(conditions) is required. - conditions: - type: list - description: - - A list of condition objects that will be set on the status.conditions field of the specified resource. - - Unless I(force) is C(true) the specified conditions will be merged with the conditions already set on the status field of the specified resource. - - Each element in the list will be validated according to the conventions specified in the - [Kubernetes API conventions document](https://github.com/kubernetes/community/blob/master/contributors/devel/api-conventions.md#spec-and-status). - - 'The fields supported for each condition are: - ` + "`type`" + ` (required), - ` + "`status`" + ` (required, one of "True", "False", "Unknown"), - ` + "`reason`" + ` (single CamelCase word), - ` + "`message`" + `, - ` + "`lastHeartbeatTime`" + ` (RFC3339 datetime string), and - ` + "`lastTransitionTime`" + ` (RFC3339 datetime string).' - - One of I(status) or I(conditions) is required.' - api_version: - description: - - Use to specify the API version. Use in conjunction with I(kind), I(name), and I(namespace) to identify a - specific object. - required: yes - aliases: - - api - - version - kind: - description: - - Use to specify an object model. Use in conjunction with I(api_version), I(name), and I(namespace) to identify a - specific object. - required: yes - name: - description: - - Use to specify an object name. Use in conjunction with I(api_version), I(kind) and I(namespace) to identify a - specific object. - required: yes - namespace: - description: - - Use to specify an object namespace. Use in conjunction with I(api_version), I(kind), and I(name) - to identify a specific object. - force: - description: - - If set to C(True), the status will be set using ` + "`PUT`" + ` rather than ` + "`PATCH`" + `, replacing the full status object. - default: false - type: bool - host: - description: - - Provide a URL for accessing the API. Can also be specified via K8S_AUTH_HOST environment variable. - api_key: - description: - - Token used to authenticate with the API. Can also be specified via K8S_AUTH_API_KEY environment variable. - kubeconfig: - description: - - Path to an instance Kubernetes config file. If not provided, and no other connection - options are provided, the openshift client will attempt to load the default - configuration file from I(~/.kube/config.json). Can also be specified via K8S_AUTH_KUBECONFIG environment - variable. - context: - description: - - The name of a context found in the config file. Can also be specified via K8S_AUTH_CONTEXT environment variable. - username: - description: - - Provide a username for authenticating with the API. Can also be specified via K8S_AUTH_USERNAME environment - variable. - password: - description: - - Provide a password for authenticating with the API. Can also be specified via K8S_AUTH_PASSWORD environment - variable. - cert_file: - description: - - Path to a certificate used to authenticate with the API. Can also be specified via K8S_AUTH_CERT_FILE environment - variable. - key_file: - description: - - Path to a key file used to authenticate with the API. Can also be specified via K8S_AUTH_KEY_FILE environment - variable. - ssl_ca_cert: - description: - - Path to a CA certificate used to authenticate with the API. Can also be specified via K8S_AUTH_SSL_CA_CERT - environment variable. - verify_ssl: - description: - - "Whether or not to verify the API server's SSL certificates. Can also be specified via K8S_AUTH_VERIFY_SSL - environment variable." - type: bool - -requirements: - - "python >= 3.7" - - "openshift >= 0.8.1" - - "PyYAML >= 3.11" -''' - -EXAMPLES = ''' -- name: Set custom status fields on TestCR - k8s_status: - api_version: apps.example.com/v1alpha1 - kind: TestCR - name: my-test - namespace: testing - status: - hello: world - custom: entries - -- name: Update the standard condition of an Ansible Operator - k8s_status: - api_version: apps.example.com/v1alpha1 - kind: TestCR - name: my-test - namespace: testing - conditions: - - type: Running - status: "True" - reason: MigrationStarted - message: "Migration from v2 to v3 has begun" - lastTransitionTime: "{{ ansible_date_time.iso8601 }}" - -- name: | - Create custom conditions. WARNING: The default Ansible Operator status management - will never overwrite custom conditions, so they will persist indefinitely. If you - want the values to change or be removed, you will need to clean them up manually. - k8s_status: - conditions: - - type: Available - status: "False" - reason: PingFailed - message: "The service did not respond to a ping" - -''' - -RETURN = ''' -result: - description: - - If a change was made, will return the patched object, otherwise returns the instance object. - returned: success - type: complex - contains: - api_version: - description: The versioned schema of this representation of an object. - returned: success - type: str - kind: - description: Represents the REST resource this object represents. - returned: success - type: str - metadata: - description: Standard object metadata. Includes name, namespace, annotations, labels, etc. - returned: success - type: complex - spec: - description: Specific attributes of the object. Will vary based on the I(api_version) and I(kind). - returned: success - type: complex - status: - description: Current status details for the object. - returned: success - type: complex -''' - - -def condition_array(conditions): - - VALID_KEYS = ['type', 'status', 'reason', 'message', 'lastHeartbeatTime', 'lastTransitionTime'] - REQUIRED = ['type', 'status'] - CAMEL_CASE = re.compile(r'^(?:[A-Z]*[a-z]*)+$') - RFC3339_datetime = re.compile(r'^\d{4}-\d\d-\d\dT\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)$') - - def validate_condition(condition): - if not isinstance(condition, dict): - raise ValueError('` + "`conditions`" + ` must be a list of objects') - if isinstance(condition.get('status'), bool): - condition['status'] = 'True' if condition['status'] else 'False' - - for key in condition.keys(): - if key not in VALID_KEYS: - raise ValueError('{} is not a valid field for a condition, accepted fields are {}'.format(key, VALID_KEYS)) - for key in REQUIRED: - if not condition.get(key): - raise ValueError('Condition ` + "`{}`" + ` must be set'.format(key)) - - if condition['status'] not in ['True', 'False', 'Unknown']: - raise ValueError('Condition ` + "`status`" + ` must be one of ["True", "False", "Unknown"], not {}'.format(condition['status'])) - - if condition.get('reason') and not re.match(CAMEL_CASE, condition['reason']): - raise ValueError('Condition ` + "`reason`" + ` must be a single, CamelCase word') - - for key in ['lastHeartBeatTime', 'lastTransitionTime']: - if condition.get(key) and not re.match(RFC3339_datetime, condition[key]): - raise ValueError('` + "`{}`" + ` must be a RFC3339 compliant datetime string'.format(key)) - - return condition - - return [validate_condition(c) for c in conditions] - - -STATUS_ARG_SPEC = { - 'status': { - 'type': 'dict', - 'required': False - }, - 'conditions': { - 'type': condition_array, - 'required': False - } -} - - -def main(): - KubernetesAnsibleStatusModule().execute_module() - - -class KubernetesAnsibleStatusModule(KubernetesAnsibleModule): - - def __init__(self, *args, **kwargs): - KubernetesAnsibleModule.__init__( - self, *args, - supports_check_mode=True, - **kwargs - ) - self.kind = self.params.get('kind') - self.api_version = self.params.get('api_version') - self.name = self.params.get('name') - self.namespace = self.params.get('namespace') - self.force = self.params.get('force') - - self.status = self.params.get('status') or {} - self.conditions = self.params.get('conditions') or [] - - if self.conditions and self.status and self.status.get('conditions'): - raise ValueError("You cannot specify conditions in both the ` + "`status`" + ` and ` + "`conditions`" + ` parameters") - - if self.conditions: - self.status['conditions'] = self.conditions - - def execute_module(self): - self.client = self.get_api_client() - - resource = self.find_resource(self.kind, self.api_version, fail=True) - if 'status' not in resource.subresources: - self.fail_json(msg='Resource {}.{} does not support the status subresource'.format(resource.api_version, resource.kind)) - - try: - instance = resource.get(name=self.name, namespace=self.namespace).to_dict() - except DynamicApiError as exc: - self.fail_json(msg='Failed to retrieve requested object: {0}'.format(exc), - error=exc.summary()) - # Make sure status is at least initialized to an empty dict - instance['status'] = instance.get('status', {}) - - if self.force: - self.exit_json(**self.replace(resource, instance)) - else: - self.exit_json(**self.patch(resource, instance)) - - def replace(self, resource, instance): - if self.status == instance['status']: - return {'result': instance, 'changed': False} - instance['status'] = self.status - try: - result = resource.status.replace(body=instance).to_dict(), - except DynamicApiError as exc: - self.fail_json(msg='Failed to replace status: {}'.format(exc), error=exc.summary()) - - return { - 'result': result, - 'changed': True - } - - def clean_last_transition_time(self, status): - '''clean_last_transition_time removes lastTransitionTime attribute from each status.conditions[*] (from old conditions). - It returns copy of status with updated conditions. Copy of status is returned, because if new conditions - are subset of old conditions, then module would return conditions without lastTransitionTime. Updated status - should be used only for check in object_contains function, not for next updates, because otherwise it can create - a mess with lastTransitionTime attribute. - - If new conditions don't contain lastTransitionTime and they are different from old conditions - (e.g. they have different status), conditions are updated and kubernetes should sets lastTransitionTime - field during update. If new conditions contain lastTransitionTime, then conditions are updated. - - Parameters: - status (dict): dictionary, which contains conditions list - - Returns: - dict: copy of status with updated conditions - ''' - updated_old_status = copy.deepcopy(status) - - for item in updated_old_status.get('conditions', []): - if 'lastTransitionTime' in item: - del item['lastTransitionTime'] - - return updated_old_status - - def patch(self, resource, instance): - # Remove lastTransitionTime from status.conditions[*] and use updated_old_status only for check in object_contains function. - # Updates of conditions should be done only with original data not with updated_old_status. - updated_old_status = self.clean_last_transition_time(instance['status']) - if self.object_contains(updated_old_status, self.status): - return {'result': instance, 'changed': False} - instance['status'] = self.merge_status(instance['status'], self.status) - try: - result = resource.status.patch(body=instance, content_type='application/merge-patch+json').to_dict() - except DynamicApiError as exc: - self.fail_json(msg='Failed to replace status: {}'.format(exc), error=exc.summary()) - - return { - 'result': result, - 'changed': True - } - - def merge_status(self, old, new): - old_conditions = old.get('conditions', []) - new_conditions = new.get('conditions', []) - if not (old_conditions and new_conditions): - return new - - merged = copy.deepcopy(old_conditions) - - for condition in new_conditions: - idx = self.get_condition_idx(merged, condition['type']) - if idx is not None: - merged[idx] = condition - else: - merged.append(condition) - new['conditions'] = merged - return new - - def get_condition_idx(self, conditions, name): - for i, condition in enumerate(conditions): - if condition.get('type') == name: - return i - return None - - def object_contains(self, obj, subset): - def dict_is_subset(obj, subset): - return all([mapping.get(type(obj.get(k)), mapping['default'])(obj.get(k), v) for (k, v) in subset.items()]) - - def list_is_subset(obj, subset): - return all(item in obj for item in subset) - - def values_match(obj, subset): - return obj == subset - - mapping = { - dict: dict_is_subset, - list: list_is_subset, - tuple: list_is_subset, - 'default': values_match - } - - return dict_is_subset(obj, subset) - - @property - def argspec(self): - args = copy.deepcopy(COMMON_ARG_SPEC) - args.pop('state') - args.pop('resource_definition') - args.pop('src') - args.update(AUTH_ARG_SPEC) - args.update(STATUS_ARG_SPEC) - return args - - -if __name__ == '__main__': - main() -` diff --git a/internal/scaffold/ansible/roles_meta_main.go b/internal/scaffold/ansible/roles_meta_main.go index ffdc6e8727..a343db95e0 100644 --- a/internal/scaffold/ansible/roles_meta_main.go +++ b/internal/scaffold/ansible/roles_meta_main.go @@ -56,7 +56,7 @@ const rolesMetaMainAnsibleTmpl = `galaxy_info: # - CC-BY license: license (GPLv2, CC-BY, etc) - min_ansible_version: 2.6 + min_ansible_version: 2.9 # If this a Container Enabled role, provide the minimum Ansible Container version. # min_ansible_container_version: @@ -97,4 +97,6 @@ const rolesMetaMainAnsibleTmpl = `galaxy_info: dependencies: [] # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list.` + # if you add dependencies to this list. +collections: +- operator_sdk.util` diff --git a/test/ansible-memcached/tasks.yml b/test/ansible-memcached/tasks.yml index 8803023af1..77d39208df 100644 --- a/test/ansible-memcached/tasks.yml +++ b/test/ansible-memcached/tasks.yml @@ -36,7 +36,7 @@ initialDelaySeconds: 3 periodSeconds: 3 -- k8s_status: +- operator_sdk.util.k8s_status: api_version: ansible.example.com/v1alpha1 kind: Memcached name: "{{ meta.name }}"